Vector Tile Service Provisioning
A vector tile service is the narrowest, busiest waist in a modern map stack: every pan and zoom in the browser becomes an HTTP request for a Mapbox Vector Tile, and each of those requests usually resolves to a ST_AsMVT aggregation running against a spatial index in PostGIS. Provisioning that tier is therefore not a matter of running one more container — it is the discipline of holding a database, a stateless renderer, a cache, and a tile-coordinate contract in exact agreement across every environment. This topic sits inside Geospatial Resource Provisioning and depends directly on the database work described in PostGIS Cluster Provisioning, because a vector tile server is only ever as fast as the generalized geometry columns and GiST indexes underneath it. Where a rendered raster tier such as GeoServer Deployment Patterns ships pixels, this tier ships geometry, and the operational consequences of that difference run through every section below.
The services in scope are the ones a platform team actually provisions from Infrastructure as Code: pg_tileserv and Martin, which publish MVT straight from PostGIS with no intermediate build step; Tegola, which adds a pluggable cache in front of the same query pattern; and Tippecanoe-built PMTiles or MBTiles archives served statically when the data is stable enough to precompute. The declarative surface differs, but the provisioning problems do not: schema parity, tile-coordinate correctness, connection-pool sizing, cache coherence, and the ability to prove all of them in a pipeline before traffic arrives.
Environment parity and configuration drift mitigation
The parity problem in a vector tile tier is subtler than in a database tier, because the renderer holds almost no state of its own. What it holds instead is an interpretation — of which schemas are published, which columns become tile attributes, what the simplification tolerance is at each zoom, and what the extent and buffer of a tile are. Two environments can run identical container images and still emit different tiles, because the interpretation is supplied by the database schema and a configuration file, and neither is pinned by the image tag.
Pin all three layers explicitly. The container image must be referenced by digest rather than by a floating tag, so a rebuild upstream cannot change the MVT encoder in production while staging keeps the old one. The configuration must be rendered from the same module in every environment, with only the connection string and the resource sizing varying. And the database contract — the list of published tables, functions, and their geometry columns — must be asserted rather than discovered: a renderer configured to auto-publish everything it can see will silently start serving a new table the moment an unrelated migration lands, which is a data-exposure incident dressed up as a convenience feature.
The tile-coordinate contract deserves its own paragraph because it is the one that produces the most confusing production incidents. A vector tile is addressed by a z/x/y triple, and the mapping from that triple to a bounding box in Web Mercator is fixed — but the extent (the integer coordinate space inside the tile, conventionally 4096) and the buffer (how far geometry is carried past the tile edge so that lines and labels do not clip at seams) are configuration. An environment that renders at extent 4096 with a 64-unit buffer and a staging environment that renders at 256 with no buffer will both look correct in isolation, and will produce visibly torn geometry the moment a client caches tiles from both.
Drift also arrives through the connection pool. A vector tile renderer opens a database connection per concurrent tile request unless a pool bounds it, and a fleet that scales out under map traffic will exhaust max_connections on the PostGIS instance long before it exhausts its own CPU. The pool size is therefore not a renderer-local tuning knob; it is a shared budget divided across the fleet, and it belongs in the same module that sizes the database. Encode it as pool_size × max_replica_count ≤ max_connections − reserved_admin_slots and assert the inequality at plan time rather than discovering it during a traffic peak.
CI/CD validation and operational guardrails
A tile service is unusually easy to validate in a pipeline, because its output is a deterministic binary for a known input. That property should be used aggressively. The minimum viable gate is a request for a small set of known tile coordinates against an ephemeral stack, asserting three things: the HTTP status is 200, the Content-Type is application/vnd.mapbox-vector-tile, and the decoded tile contains the expected layer names with a non-zero feature count. A tile that returns 200 with an empty body is the single most common silent failure in this tier, and it passes any check that only looks at status codes.
Layer-level assertions catch schema regressions that a smoke test misses. If a migration renames road_class to roadclass, the tile still renders, still returns 200, and still has features — and every style rule in the client that keys on the old attribute silently stops matching, producing an unstyled grey map. Decoding one tile in CI and asserting the attribute keys of the first feature in each layer turns that from a visual bug report into a failed build.
Policy-as-code gates carry the security half. The rules worth enforcing on every plan are narrow and mechanical: the renderer’s database role must be read-only, the published schema list must be explicit rather than a wildcard, the service must not be reachable on a public IP without a CDN or load balancer in front of it, and the container image must be digest-pinned. Each of these maps to a real incident class, and each is cheap to express in the same Policy as Code for Spatial Resources pipeline that governs the rest of the estate.
Resource architecture and service integration
The deployed shape is four collaborating components, and the boundaries between them are where the provisioning decisions live. At the edge sits a CDN or load balancer that terminates TLS and caches by full tile URL. Behind it runs the stateless renderer fleet, sized on concurrency rather than on data volume. Behind that is PostGIS, which owns every byte of authoritative geometry. Alongside the renderer sits an optional tile cache — Tegola’s file or object-storage cache, or a CDN-only strategy — whose invalidation semantics decide how quickly an edit becomes visible.
The integration with PostGIS is the most consequential. The renderer should authenticate as a role that can SELECT from an explicitly enumerated set of tables and functions and nothing else, sourced from the secrets pattern in Secrets Management for Spatial Pipelines rather than from an environment variable baked into a task definition. Read traffic should be pointed at replicas where the read/write split exists, since tile rendering is pure read and is exactly the workload replicas are for. Network reachability should follow the private paths established in VPC Routing for Tile Servers — the renderer needs the database, and the internet needs the renderer only through the edge.
The integration with object storage matters for the precomputed variant. When the data changes rarely enough — administrative boundaries, contour lines, a published basemap — building a PMTiles archive with Tippecanoe and serving it from a bucket removes the database from the request path entirely, converting a scaling problem into a storage problem. The bucket layout, lifecycle, and access policy then follow the conventions in Object Storage for Raster and Vector Data. The decision between live and precomputed is not global: most production platforms run both, live for editable operational layers and precomputed for the stable cartographic base.
Choosing between live rendering and precomputed archives
Every layer a platform publishes sits somewhere on a spectrum between “changes every few seconds” and “changes twice a year”, and the correct provisioning shape follows directly from where it sits. Live rendering answers each request by querying PostGIS, so an edit is visible on the next uncached request and the infrastructure cost is a renderer fleet plus database read capacity that scales with traffic. A precomputed archive is built once by Tippecanoe into PMTiles or MBTiles, uploaded to object storage, and served as bytes, so the infrastructure cost is storage plus egress and the freshness cost is a full rebuild.
The trap is treating this as a single platform-wide decision. A parcels layer that planners edit continuously must be live, because a nightly rebuild would make the editing tool useless. A hillshade-derived contour layer regenerated when new elevation data lands should never be live, because paying for a database read on every request to serve geometry that changed last quarter is a permanent tax on a one-off event. Most mature platforms therefore run both paths against the same database, with the build pipeline for the precomputed layers scheduled by the same orchestration described in Pipeline Orchestration for Spatial Deploys.
The economics are worth stating concretely, because they usually decide the argument. A live tier’s marginal cost per tile is a database query, and that query does not get cheaper as traffic grows — it gets more expensive, because concurrency pressure on the spatial index rises. A precomputed tier’s marginal cost per tile is an object read plus egress, and the CDN absorbs the overwhelming majority of requests before they reach the bucket at all. For a stable basemap under real map traffic the precomputed path is usually an order of magnitude cheaper, which is exactly the kind of finding the modelling in Cost Estimation Frameworks is meant to surface before the architecture is fixed.
The provisioning consequence of running both is that the two paths must not diverge in their tile contract. A client that fetches an operational layer live and a basemap from an archive is compositing them in one map, and if the two were built with different extents the overlay will not line up at high zoom. Generate both from the same pinned values, and treat that pinning as a shared module input rather than as two independent configurations that happen to agree today.
Runnable configuration
The following module provisions a pg_tileserv service on ECS Fargate behind an application load balancer, with a digest-pinned image, an explicit published-schema list, a bounded connection pool, and the database credential injected from Secrets Manager rather than from plaintext.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
variable "vpc_id" { type = string }
variable "private_subnet_ids" { type = list(string) }
variable "db_secret_arn" { type = string }
# Pool size is a share of the database's connection budget, not a local knob:
# pool_size * max_capacity must stay below max_connections minus admin reserve.
variable "pool_size" {
type = number
default = 8
}
variable "max_capacity" {
type = number
default = 6
}
resource "aws_ecs_task_definition" "tileserv" {
family = "pg-tileserv"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 1024
memory = 2048
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([{
name = "tileserv"
# Digest-pinned: a floating tag would let an upstream rebuild change the MVT
# encoder in production while staging keeps the old one.
image = "pramsey/pg_tileserv@sha256:${var.tileserv_digest}"
portMappings = [{ containerPort = 7800, protocol = "tcp" }]
environment = [
# Publish an explicit schema list. A wildcard would start serving any new
# table the moment an unrelated migration lands.
{ name = "TS_SCHEMAS", value = "tiles,reference" },
# Extent and buffer are the tile contract — identical in every environment.
{ name = "TS_DEFAULTMVTEXTENT", value = "4096" },
{ name = "TS_DEFAULTBUFFER", value = "64" },
{ name = "TS_DBPOOLMAXCONNS", value = tostring(var.pool_size) },
{ name = "TS_CORSORIGINS", value = "https://maps.example.org" },
]
secrets = [
{ name = "DATABASE_URL", valueFrom = var.db_secret_arn },
]
healthCheck = {
# Probe a real tile, not just the index: an empty 200 is the failure mode.
command = ["CMD-SHELL", "curl -fsS http://localhost:7800/index.json || exit 1"]
interval = 30
retries = 3
}
}])
}
resource "aws_appautoscaling_target" "tileserv" {
service_namespace = "ecs"
resource_id = "service/${aws_ecs_cluster.maps.name}/${aws_ecs_service.tileserv.name}"
scalable_dimension = "ecs:service:DesiredCount"
min_capacity = 2
max_capacity = var.max_capacity
}
variable "tileserv_digest" { type = string }
Two details in that configuration repay attention. The health check requests a real endpoint rather than a TCP probe, because a renderer whose database connection has failed will still accept TCP and still bind its port — a TCP health check keeps a dead task in the load balancer’s target group indefinitely. And max_capacity is bounded rather than open-ended, because unbounded autoscaling on a tile tier does not fail gracefully: it scales until the database refuses connections, at which point every replica fails instead of a subset queuing.
Guardrails embedded in the configuration
- State locking during renderer rollouts. Tile services are redeployed far more often than the database beneath them, so the odds of two applies overlapping are correspondingly higher. A locked remote backend, as covered in State Backend Selection, prevents a half-updated task definition from being registered while a second run rewrites the same service.
- Credentials by reference only. The database URL enters the container through the
secretsblock, neverenvironment. This keeps the value out of task-definition JSON, out of plan output, and out of the pull-request comment that renders the plan. - Read-only database role. The renderer’s role holds
SELECTon the published schemas and nothing else.ST_AsMVTneeds no write privilege, so any write grant on this role is surplus and becomes an exfiltration or corruption path the moment the renderer is compromised. - Private subnets with edge-only ingress. Tasks run in private subnets; the only route in is the load balancer’s security group. The renderer never receives a public IP, which removes the entire class of incidents where a service is discovered by internet-wide scanning before the CDN is attached.
- Bounded pool arithmetic asserted at plan time. The relationship between pool size, maximum replica count and the database’s
max_connectionsis a precondition, and expressing it as a validation rule turns a peak-traffic outage into a failed plan.
Troubleshooting and failure modes
1. Empty tiles with HTTP 200. The renderer answers, the body decodes, and there are no features. The usual causes are a geometry column in a projection the renderer is not reprojecting from, a bounding box that genuinely contains no data at that zoom, or a published table whose geometry column is not the one the query targets. Distinguish them by running the equivalent ST_AsMVT query by hand against the same bbox: if the query returns rows and the tile does not, the fault is in the publication configuration, not the data.
2. Connection-pool exhaustion under map traffic. Symptoms are a sudden cliff rather than a slope — latency is flat and then every request fails with a connection error, because the pool is a hard boundary. Confirm with SELECT count(*) FROM pg_stat_activity WHERE usename = 'tileserv' against max_connections, then fix the arithmetic rather than raising the pool: a larger pool moves the cliff without removing it.
3. Torn geometry at tile seams. Lines break and labels disappear exactly on tile boundaries. This is a buffer mismatch — either the buffer is zero, or tiles rendered with different buffers are mixed in the client cache after a configuration change. Correcting the configuration is not sufficient; the cached tiles must be invalidated too, or the client keeps serving the torn ones.
4. Attribute drift after a migration. Tiles render, features exist, and the map is unstyled grey because a renamed column no longer matches the style. Only a decoding assertion in CI catches this before release; a status-code check never will.
5. Cache poisoning across environments. A staging renderer and a production renderer sharing a CDN cache key namespace will serve each other’s tiles. Tile URLs contain no environment discriminator by default, so the namespace must be created deliberately — a distinct hostname or a cache-key prefix — or the first shared deployment produces a genuinely confusing incident.
6. Autoscaling that thrashes on cache-warm cycles. Tile traffic is spiky and cache-mediated, so scaling on raw request count causes the fleet to add and remove tasks in a cycle that never settles. Scale on a smoothed concurrency or CPU signal with a cooldown longer than the CDN’s cache fill time.
Related
- Geospatial Resource Provisioning — the parent section covering every provisioned spatial resource class
- PostGIS Cluster Provisioning — the database and indexes every live vector tile query depends on
- GeoServer Deployment Patterns — the rendered-raster counterpart to this geometry-serving tier
- Object Storage for Raster and Vector Data — bucket layout for precomputed PMTiles archives
- VPC Routing for Tile Servers — the private network paths between renderer and database