Can You Run a Vector Tile Server on AWS Lambda Without PostGIS?
The quick answer is yes, and here is how we did it. Serving vector map tiles are typically associated with long-running servers like PostGIS, pg_tileserv, Martin, or a full GeoServer stack that can result in high monthly costs. We took a different approach to keep the Canadian Institute of Forestry (CIF) API lightweight and low cost: a serverless vector tile server running on AWS Lambda, backed by SQLite and SpatiaLite.
Architecture

The Stack
The API is a Go service built on the Echo framework. It serves:
- Vector tiles (Mapbox Vector Tile format) for tree inventory data
- Data exports in GeoJSON, Shapefile, and CSV
- Count and filter queries by species, location, bounding box, and date
The database is SQLite with the SpatiaLite extension — a lightweight spatial engine that brings R-tree indexes and geometry functions to SQLite. No Postgres, no PostGIS, no persistent database server.
Tile requests hit a single endpoint that resolves the tile bounds and dispatches to the appropriate layer handler:
func TileHandler(c echo.Context) error {
treeSearch, _ := services.ParseQueryParams(c)
x, _ := strconv.ParseUint(c.Param("x"), 10, 32)
y, _ := strconv.ParseUint(c.Param("y"), 10, 32)
z, _ := strconv.ParseUint(c.Param("z"), 10, 32)
zoom := maptile.Zoom(z)
tile := maptile.New(uint32(x), uint32(y), zoom)
switch c.Param("layer") {
case "cities":
return showCities(tile, treeSearch, c)
case "trees":
treeSearch.BoundingBox = ptr(tile.Bound())
return showTrees(tile, treeSearch, c)
}
return echo.NewHTTPError(http.StatusBadRequest, "Invalid layer parameter")
}
Each layer handler queries the database, builds a GeoJSON feature collection, then encodes it as a Mapbox Vector Tile:
layer := mvt.NewLayer("trees", collection)
layer.Version = 2
layer.Extent = mvt.DefaultExtent
layer.Clip(tile.Bound())
layer.ProjectToTile(tile)
data, _ := mvt.Marshal(mvt.Layers{layer})
c.Response().Header().Set(echo.HeaderContentType, "application/vnd.mapbox-vector-tile")
return c.Blob(http.StatusOK, "", data)
Why Lambda for a Tile Server?
Tile servers are stateless by design, where each request is independent, carries no session, and produces a deterministic output from its inputs. What makes them feel stateful is their typical deployment: a persistent process holding open database connections and warming an in-memory query cache. Lambda’s ephemeral execution model removes those assumptions, which requires some rethinking.
The CIF dataset has a few properties that make Lambda work well regardless:
- The data is read-only. There are no writes to the database during tile requests. A single SQLite file opened in immutable, read-only mode is safe to use from multiple concurrent Lambda execution environments. Each environment gets its own copy of the container image and therefore its own database file handle. Invocations on the same execution environment reuse the open connection.
- The dataset fits in a container image. The SQLite database is bundled directly into the Lambda Docker image. Cold starts load the database off disk rather than over a network, keeping latency predictable.
- Spatial queries are fast. SpatiaLite’s R-tree spatial index makes bounding-box lookups for tile requests very fast. Tile generation — query, encode to MVT, return — is well within Lambda’s execution model.
- Traffic is bursty, not sustained. A forest inventory API doesn’t serve millions of concurrent users. Lambda’s scale-to-zero model means we pay only for what we use.
Streaming Responses
Standard Lambda invocations buffer the entire response before returning it. For large data exports (shapefiles, GeoJSON of tens of thousands of trees), this creates problems: timeouts, memory pressure, and poor perceived performance.
We use Lambda’s response streaming mode (RESPONSE_STREAM invoke type via a Lambda Function URL). This lets the Go handler write data to the response incrementally (the same way it would on a traditional HTTP server). For tile requests this makes no practical difference, but for downloads it’s essential.
SpatiaLite on Lambda: The Packaging Challenge
SpatiaLite is a C extension to SQLite. It requires native shared libraries (mod_spatialite.so) to be present at runtime. Lambda’s standard runtimes don’t include them.
Our solution: a custom Docker base image that compiles SpatiaLite against Amazon Linux 2 and stores the result in ECR. The application image inherits from this base.
The Lambda Dockerfile copies the compiled libraries into /var/task/lib/ and sets the library path at build time:
# Final Lambda runtime stage
FROM public.ecr.aws/lambda/provided:al2-x86_64
# Lambda Web Adapter translates HTTP requests to Lambda invocations
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 \
/lambda-adapter /opt/extensions/lambda-adapter
# Copy SpatiaLite libraries from the base builder stage
COPY --from=builder /usr/local/lib64/ /var/task/lib/
COPY --from=builder /usr/local/lib/ /var/task/lib/
# Bundle the database directly in the image
COPY data/cif.db /var/task/data/cif.db
ENV LD_LIBRARY_PATH=/var/task/lib
ENV SPATIALITE_LIBRARY_PATH=/var/task/lib/mod_spatialite.so
ENV DATABASE_PATH=/var/task/data/cif.db
ENV AWS_LWA_INVOKE_MODE=response_stream
CMD ["bootstrap"]
The Go application detects the Lambda environment at startup and confirms the library path before the SpatiaLite driver initialises:
func init() {
if os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "" {
if _, err := os.Stat("/var/task/lib/mod_spatialite.so"); err == nil {
os.Setenv("SPATIALITE_LIBRARY_PATH", "/var/task/lib/mod_spatialite.so")
} else {
os.Setenv("SPATIALITE_LIBRARY_PATH", "/var/task/lib/libspatialite.so")
}
}
}
This base image is rebuilt monthly via a scheduled GitHub Actions workflow. The reason is security patching rather than feature updates: SpatiaLite depends on several C libraries (GEOS, PROJ, libsqlite3) that ship with the Amazon Linux 2 base and can accumulate CVEs between releases. A monthly rebuild picks up OS-level patches automatically.
A scheduled rebuild carries one real risk: an upstream package update changes behavior between releases. We handle this by pinning GEOS and PROJ to specific versions in the base image, since those are the libraries most likely to affect query results if they shift underneath us. OS-level packages stay unpinned so security patches still flow through automatically. Every rebuilt image runs through testing before it’s promoted to production, so a bad patch gets caught before it reaches the live API.
Networking: VPC + Cost-Optimized NAT
The Lambda function runs inside a VPC for access to other private resources (email secrets, S3 via VPC endpoint). That requires a NAT gateway for outbound internet access, which is normally a $30+/month fixed cost even at zero traffic.
We use fck-nat instead: a community-maintained NAT solution that runs as a t4g.nano Auto Scaling Group. It costs roughly $3–5/month and saves ~$27/month over the managed alternative regardless of traffic.
That saving comes with real caveats worth understanding:
- Bandwidth ceiling. A t4g.nano has modest network throughput. AWS Managed NAT supports up to 5 Gbps burst; a nano instance is considerably lower. For a read-heavy tile API with small response payloads this is fine, but it would be a bottleneck for high-throughput workloads.
- No AWS SLA. Managed NAT Gateway carries a 99.9% availability SLA. fck-nat is a community project running on EC2 — if the instance is replaced or the Auto Scaling Group takes time to recover, outbound traffic is briefly interrupted.
- Brief outage on instance replacement. When the NAT instance is recycled (e.g. spot interruption, AMI update), there is a short window where outbound connectivity drops. In-flight Lambda invocations that need outbound access (email sending, Secrets Manager calls) will fail during that window.
For a low-traffic, read-heavy public API where the main outbound calls are occasional email sends and Secrets Manager lookups on cold starts, these trade-offs are acceptable. For anything with sustained high throughput or strict availability requirements, the managed NAT Gateway cost is justified.
Infrastructure as Code: Terramate + OpenTofu
The infrastructure is managed with OpenTofu (the open-source Terraform fork) orchestrated by Terramate.
Each environment (dev and prod) consists of three stacks with an explicit dependency order:
vpc → lambda → nat
Terramate generates the .tf files from .tm.hcl templates, which lets us share module definitions across environments while keeping per-environment configuration cleanly separated.
Directory layout
iac/
├── modules/
│ └── aws/
│ ├── vpc/ # VPC module templates
│ ├── lambda/ # Lambda module templates
│ └── fck_nat/ # NAT module templates
└── stacks/
├── project-config.tm.hcl # Project-wide defaults
├── dev/
│ ├── env-config.tm.hcl # Dev environment globals
│ ├── providers.tm.hcl # AWS provider + S3 backend
│ ├── vpc/
│ │ └── stack.tm.hcl
│ ├── lambda/
│ │ └── stack.tm.hcl
│ └── fck_nat/
│ └── stack.tm.hcl
└── prod/
└── ... # Mirrors dev/
Configuration files
terramate.tm.hcl at the repo root sets project-wide globals and Terramate behaviour:
globals {
project_name = "cif-api"
aws_region = "us-east-1"
aws_account_id = "<your-account-id>"
common_tags = {
Project = global.project_name
ManagedBy = "Sparkgeo"
}
tf_backend_bucket = "<your-state-bucket>"
tf_backend_region = global.aws_region
}
terramate {
required_version = ">= 0.15.4"
config {
git {
default_branch = "develop"
check_remote = false
}
run {
env {
TF_PLUGIN_CACHE_DIR = "${terramate.root.path.fs.absolute}/.tfdata/${terramate.stack.path.relative}/plugins"
TF_DATA_DIR = "${terramate.root.path.fs.absolute}/.tfdata/${terramate.stack.path.relative}/data"
}
}
}
}
stacks/project-config.tm.hcl defines default values that any stack can override:
globals {
lambda = {
runtime = "provided.al2"
timeout = 300
memory_size = 6144
handler = "bootstrap"
architecture = "x86_64"
}
s3 = {
create_bucket = true
versioning = true
}
aws_region = "us-east-1"
project_name = "cif"
}
stacks/dev/env-config.tm.hcl sets the environment name and provider versions for dev:
globals "env" {
tofu_version = "1.10.3"
providers = {
aws_version = "6.4.0"
}
environment = "dev"
name = "dev"
tags = {
Env = "dev"
}
}
stacks/dev/providers.tm.hcl simply imports the shared provider template (which generates the AWS provider block and S3 backend):
import {
source = "../../modules/aws/providers.tm.hcl"
}
Each stack definition then imports its module and sets the globals it needs. Here are the three dev stacks:
stacks/dev/vpc/stack.tm.hcl:
stack {
name = "vpc"
description = "Development VPC"
tags = ["dev", "vpc"]
id = "863eb412-b8f2-4ed7-b07e-e4f0c00d67b8"
}
import {
source = "/iac/modules/aws/vpc/*.tm.hcl"
}
globals {
stack_name = "dev"
vpc = {
name = "dev"
module_version = "~> 6.2.0"
cidr_block = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]
}
vpc_endpoints = {
s3 = {
vpc_name = global.vpc.name
service_name = "com.amazonaws.${global.aws_region}.s3"
type = "Gateway"
}
}
}
stacks/dev/lambda/stack.tm.hcl:
stack {
name = "lambda"
tags = ["dev", "lambda", "api"]
id = "lambda-dev-api"
}
import {
source = "/iac/modules/aws/lambda/*.tm.hcl"
}
globals {
stack_name = "dev"
lambda_function_name = "${global.project_name}-api-${global.env.environment}"
secret_arn = "arn:aws:secretsmanager:${global.aws_region}:${global.aws_account_id}:secret:<secret-name>"
lambda_config = {
module_version = "~> 8.1.2"
vpc_config = true
create_function_url = true
function_url_auth_type = "NONE"
memory_size = 6144
timeout = 300
log_retention_days = 7
cors_allow_methods = ["*"]
cors_allow_headers = ["*"]
cors_allow_origins = ["*"]
}
lambda_environment_variables = {
ENVIRONMENT = "dev"
LOG_LEVEL = "debug"
}
}
stacks/dev/fck_nat/stack.tm.hcl:
stack {
name = "fck_nat"
description = "Dev NAT via fck-nat"
tags = ["dev", "fck_nat"]
}
import {
source = "/iac/modules/aws/fck_nat/*.tm.hcl"
}
globals {
stack_name = "dev"
}
The shared module template uses those globals to generate the actual Terraform resource, including the IAM policy statements and VPC configuration. Switching from dev to prod is a single globals change — prod differs only in log retention (30 days vs 7), log level (info vs debug), and a reserved concurrency limit.
Deployment is path-filtered in CI: changes to iac/ trigger an infrastructure plan and apply; changes to src/ or the Dockerfile trigger an application build and Lambda update. The two are independent jobs that can run concurrently:
detect-changes:
uses: dorny/paths-filter
filters: |
infra:
- 'iac/**'
- 'terramate.tm.hcl'
app:
- 'src/**'
- 'Dockerfile.lambda'
deploy-infra:
if: steps.detect-changes.outputs.infra == 'true'
run: terramate run --tags ${{ env.ENVIRONMENT }} tofu apply -auto-approve
deploy-app:
if: steps.detect-changes.outputs.app == 'true'
needs: deploy-infra
run: |
just build-and-push
just update-function
Database Tuning for Lambda
A few SQLite PRAGMAs matter significantly in the Lambda context. These are applied immediately after opening the connection:
dsn := "file:" + dbPath + "?mode=ro&immutable=1&nolock=1"
db, _ := sql.Open("spatialite", dsn)
// Single connection — Lambda concurrency is handled by spinning up new instances
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
// 60s lock timeout — multiple invocations may briefly contend on the same file handle
db.Exec("PRAGMA busy_timeout = 60000")
// Avoid temp files on the network filesystem
db.Exec("PRAGMA temp_store = MEMORY")
// Memory-mapping a file over a network FS performs poorly
db.Exec("PRAGMA mmap_size = 0")
// Belt-and-suspenders read-only enforcement
db.Exec("PRAGMA query_only = ON")
Spatial queries use a two-step pattern: an R-tree index lookup for fast candidate selection, followed by ST_Within for precise filtering:
spatialIndexFilter := fmt.Sprintf(`
%s.ROWID IN (
SELECT ROWID FROM SpatialIndex
WHERE f_table_name = '%s'
AND f_geometry_column = 'location'
AND search_frame = ST_GeomFromText('%s', 4326)
)`, table, table, wktPolygon)
sb.Where(spatialIndexFilter)
sb.Where(fmt.Sprintf(`ST_Within(location, ST_GeomFromText('%s', 4326))`, wktPolygon))
The connection pool is sized to a single connection. Lambda invocations are single-threaded per instance; additional concurrency comes from Lambda spinning up new instances, not from connection pooling within one.
Cost
One of the strongest arguments for this architecture is the bill.
Lambda free tier
AWS Lambda’s perpetual free tier includes:
- 1,000,000 requests/month
- 400,000 GB-seconds of compute/month
The GB-seconds allowance is where the memory configuration matters. This API uses 6 GB of Lambda memory, so the free compute budget works out to:
400,000 GB-seconds ÷ 6 GB = ~66,666 seconds of execution time/month
A typical tile request completes in 200–500 ms. At 400 ms average, the free tier covers roughly 166,000 tile requests per month before any Lambda charges appear. A full data export might run for 5–10 seconds, but those are far less frequent than tile requests.
For a low-traffic public dataset API, Lambda costs are effectively zero.
The bill compared to the alternative
The Lambda free tier doesn’t cover everything. Here’s a breakdown of the costs against a comparable traditional deployment is a small EC2 instance running a Go binary against a PostGIS database, which would look something like:
| Serverless Model | Comparable Traditional Model | ||
|---|---|---|---|
| Resource | Monthly Cost | Alternative Resource | Monthly cost |
| fck-nat (t4g.nano) | ~$3–5/month | t3.small EC2 (app server) | ~$15/month |
| ECR image storage | ~$0.10/month | db.t3.micro RDS PostgreSQL + PostGIS | ~$25/month |
| CloudWatch Logs | ~$0/month | NAT Gateway | ~$32/month |
| S3 (data exports cache) | ~$0/month | ||
| VPC, subnets, IGW | ~$0/month | ||
| TOTAL | ~$3.10/month | TOTAL | ~$72/month |
The fck-nat instance is the only meaningful fixed cost and it replaces a managed NAT Gateway that would cost $30+/month regardless of traffic. For a low-traffic API, the managed NAT bill would dwarf everything else combined.
The Lambda architecture brings that to roughly $4–5/month at low traffic, scaling only when usage grows. The break-even point, where Lambda invocation costs exceed the fixed EC2/RDS bill, is well into the millions of requests per month, far beyond what a forest inventory API typically sees.
What Works, What Doesn’t
Works well:
- Tile serving for moderate traffic.
- Large data exports via streaming.
- Zero operational overhead (no servers to patch, no database to manage).
- Scale-to-zero cost model.
Trade-offs:
- Cold starts add ~1–2s latency on first request after inactivity.
- Lambda container images have a 10 GB size limit (uncompressed). The SQLite database must fit within that budget alongside the binary and SpatiaLite libraries. For larger datasets, alternatives include mounting via EFS or Mountpoint for Amazon S3, which allows an S3-hosted file to be accessed as a local filesystem path, potentially removing the need to bundle the database in the image entirely.
- No in-memory query caching across execution environments, each cold start begins with an empty cache.
For a domain dataset like the CIF’s national forest tree inventory, this architecture hits a practical sweet spot: full spatial query capability, reasonable performance, and near-zero infrastructure overhead.
