Provisioning pg_tileserv on ECS Fargate

pg_tileserv is a single Go binary that publishes Mapbox Vector Tiles straight from PostGIS, with no build step and no intermediate cache. That simplicity is what makes it attractive and what makes its failure modes concentrated: everything the service does is a database query, so every operational problem is a database problem wearing a tile-shaped disguise. Provisioning it on Fargate is therefore mostly about three decisions — what the service is allowed to publish, how many connections it may hold, and how its health is judged — and getting any of them wrong produces a service that looks healthy and serves nothing. This guide extends Vector Tile Service Provisioning within Geospatial Resource Provisioning.

What the service must not be allowed to do

Start from the restrictions, because the defaults are permissive in ways that matter.

It must not auto-publish. Left unconfigured, pg_tileserv publishes every table with a geometry column that its role can see. A migration adding an unrelated table with a geometry column then puts that table on a public tile endpoint, with no deployment, no review and no notification. Set an explicit schema list, and give the role visibility only into schemas that are meant to be public.

It must not hold a write-capable role. Rendering is SELECT only. A write grant on this role is surplus capability on the most internet-exposed component in the stack.

It must not take an unbounded share of the connection budget. The pool size multiplied by the maximum replica count is a claim on the database’s max_connections, and a service that scales freely will exhaust it during exactly the traffic peak that caused the scaling.

It must not receive a public address. The service belongs in private subnets behind a load balancer and a CDN, following the private paths in VPC Routing for Tile Servers.

Four restrictions that define a safe tile service An explicit schema list prevents the service auto-publishing any table with a geometry column that its role can see, which would otherwise put an unrelated new table on a public endpoint with no deployment or review. A read-only database role removes surplus capability from the most internet-exposed component in the stack. A bounded connection pool keeps pool size multiplied by maximum replica count below the database's connection limit minus an administrative reserve, so scaling under a traffic peak cannot exhaust the database. Private subnets with ingress only from the load balancer keep the service off the public internet entirely. Explicit schemas no auto-publish a new table cannot appear on the endpoint Read-only role SELECT and nothing else ST_AsMVT needs no more Bounded pool pool × max replicas < max_connections minus admin reserve Private subnets no public address ingress only from the load balancer Every one of these is a default that is permissive in the wrong direction auto-publish is on, the pool is unbounded relative to the fleet, and a task will take a public IP if asked so the module must state all four explicitly rather than relying on what happens to be configured

Prerequisites and environment assumptions

Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A PostGIS cluster with a read replica if the platform has one, since tile rendering is pure read. A read-only database role whose search_path and grants cover only the publishable schemas. The connection secret in a secrets manager, per Secrets Management for Spatial Pipelines. A container image referenced by digest.

Compute the pool arithmetic before writing the task definition, because it is an input to both the service and the database. With max_connections at 200, twenty slots reserved for administration and migrations, and a maximum of six replicas, the pool ceiling is (200 − 20) / 6 = 30. Choosing eight leaves substantial headroom for a second consumer, which is usually the right call, since the database rarely serves only the tile tier.

Step-by-step provisioning

  1. Create the read-only role and the publishable schema. Grant USAGE on the schema and SELECT on its tables, and nothing else. Views are often better than tables here: a view can pre-filter to the rows that are genuinely public and select only the columns that should become tile attributes, which is a far tighter publication boundary than table grants.

  2. Define the task with explicit configuration and no secrets in the environment. The schema list, extent and buffer are environment variables; the database URL is a secret reference. Extent and buffer are the tile contract, so they belong in a shared module input rather than being typed per environment.

  3. Health-check a real tile, not the port. A renderer whose database connection has failed still binds its port and still accepts TCP. Probe an endpoint that requires the database — the layer index at minimum — so a functionally dead task leaves the target group.

  4. Attach an internal load balancer and a CDN. The service is private; the load balancer is internal; the CDN is the public face and does the caching, which is what keeps origin request volume a small fraction of map traffic.

  5. Bound autoscaling explicitly. Minimum two for availability, maximum whatever the pool arithmetic allows. An unbounded maximum does not fail gracefully: it scales until the database refuses connections, at which point every replica fails rather than a subset queuing.

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.60" }
  }
}

variable "max_connections" { type = number }
variable "admin_reserve" {
  type    = number
  default = 20
}
variable "max_replicas" {
  type    = number
  default = 6
}
variable "pool_size" {
  type    = number
  default = 8
}
variable "tileserv_digest" { type = string }
variable "db_secret_arn" { type = string }

# The pool arithmetic as a precondition: a fleet that can outgrow the database
# fails at its own traffic peak, and this turns that into a failed plan.
resource "terraform_data" "pool_budget" {
  lifecycle {
    precondition {
      condition = var.pool_size * var.max_replicas <= var.max_connections - var.admin_reserve
      error_message = format(
        "pool_size(%d) * max_replicas(%d) = %d exceeds the usable budget of %d.",
        var.pool_size, var.max_replicas,
        var.pool_size * var.max_replicas,
        var.max_connections - var.admin_reserve,
      )
    }
  }
}

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"
    image = "pramsey/pg_tileserv@sha256:${var.tileserv_digest}"
    portMappings = [{ containerPort = 7800, protocol = "tcp" }]

    environment = [
      # Explicit list. Without it the service publishes every table with a
      # geometry column that its role can see, including tomorrow's.
      { name = "TS_SCHEMAS", value = "tiles" },
      # The tile contract — identical across environments, or geometry tears
      # at seams when a client mixes tiles from both.
      { 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" },
    ]

    # By reference only: this keeps the value out of the task definition JSON,
    # out of plan output, and out of the pull-request comment rendering it.
    secrets = [{ name = "DATABASE_URL", valueFrom = var.db_secret_arn }]

    healthCheck = {
      # Requires the database. A TCP check would keep a renderer that has lost
      # its connection in the target group indefinitely.
      command  = ["CMD-SHELL", "curl -fsS http://localhost:7800/index.json | grep -q tiles. || exit 1"]
      interval = 30
      timeout  = 5
      retries  = 3
      startPeriod = 20
    }

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.tileserv.name
        "awslogs-region"        = data.aws_region.current.name
        "awslogs-stream-prefix" = "tileserv"
      }
    }
  }])
}

resource "aws_ecs_service" "tileserv" {
  name            = "pg-tileserv"
  cluster         = aws_ecs_cluster.maps.id
  task_definition = aws_ecs_task_definition.tileserv.arn
  desired_count   = 2
  launch_type     = "FARGATE"

  network_configuration {
    subnets = var.private_subnet_ids
    # Never public: the CDN and the internal load balancer are the only path in.
    assign_public_ip = false
    security_groups  = [aws_security_group.tileserv.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.tileserv.arn
    container_name   = "tileserv"
    container_port   = 7800
  }
}

data "aws_region" "current" {}
variable "private_subnet_ids" { type = list(string) }
-- A publishing view is a tighter boundary than a table grant: it fixes which
-- rows are public and which columns become tile attributes.
CREATE SCHEMA IF NOT EXISTS tiles;

CREATE VIEW tiles.parcels AS
SELECT id,
       parcel_ref,
       land_use,
       -- Generalized geometry: serving full-resolution polygons at low zoom
       -- is the most common cause of slow tiles and enormous payloads.
       ST_SimplifyPreserveTopology(geom, 1.0) AS geom
FROM public.parcels
WHERE is_public IS TRUE;

CREATE ROLE tileserv LOGIN;
GRANT USAGE ON SCHEMA tiles TO tileserv;
GRANT SELECT ON ALL TABLES IN SCHEMA tiles TO tileserv;
-- No grants on public. The renderer cannot see the base table at all.
A port check keeps a dead renderer in rotation A renderer whose database connection has failed continues to bind its listening port and complete TCP handshakes normally. A health check that only tests the port therefore reports the task as healthy, the load balancer keeps sending it traffic, and every tile request it receives returns an error. A health check that requests the layer index must reach the database to answer, so the same failed task fails the check within a few intervals and is removed from the target group, and the service scales a replacement. Renderer, no database port still bound TCP port check passes — handshake succeeds GET /index.json fails — needs the database Stays in rotation serving errors indefinitely Removed and replaced within a few intervals

Verification

Request a tile at a coordinate you know contains data and confirm three things in order: HTTP 200, Content-Type: application/vnd.mapbox-vector-tile, and a non-empty body that decodes to the expected layer with a feature count above zero. Checking only the status code misses the characteristic failure of this tier, which is an empty tile returned successfully.

Confirm the restrictions actually hold. Attempt an INSERT as the tileserv role and require it to fail. Request a tile for a table that exists in public but not in tiles and require a 404 — if it renders, the schema list is not doing its job. Check pg_stat_activity for the role’s connection count under load and confirm it stays within the pool ceiling. Finally confirm the task has no public address and that the security group permits ingress only from the load balancer.

Three checks on one tile, and why all three are needed A tile response is verified in three stages, each catching something the previous one cannot. The status code confirms the request reached a working service. The content type confirms the service produced a vector tile rather than an error document with a success status. And decoding the body confirms the tile contains the expected layer with at least one feature. Only the third catches the characteristic failure of this tier, which is a structurally valid but empty tile returned with every earlier signal healthy. 1 · Status is 200 the request reached a service and proves nothing else 2 · Content type a vector tile, not an error page an empty tile still passes 3 · Decode the body expected layer present feature count above zero Only the third check catches an empty tile returned successfully — the characteristic failure of this tier. Every earlier signal is healthy while the map renders nothing.

Preventing recurrence

  • Assert the pool arithmetic at plan time. The precondition above turns a peak-traffic outage into a failed plan, which is the cheapest place for it to happen.
  • Decode a tile in CI. A status-code check will pass on an empty tile forever; a decode assertion on layer names and attribute keys catches schema drift at merge, as described in Testing and Validation for Spatial IaC.
  • Publish through views, not tables. A view fixes both the row filter and the attribute set, so a column added to the base table does not silently become a tile attribute.
  • Alarm on pool utilisation, not only on latency. Saturation is a cliff rather than a slope, so latency gives almost no warning while utilisation gives minutes.

Frequently Asked Questions

Why does my tile return 200 with no features?

Most often the geometry is in a projection the service is not reprojecting from, the view filters out every row in that extent, or the published relation’s geometry column is not the one the query targets. Run the equivalent ST_AsMVT query by hand against the same bounding box: if it returns rows and the tile does not, the fault is in publication, not in the data.

Should the service point at the primary or a replica?

A replica, whenever one exists. Tile rendering is pure read and is precisely the workload replicas are for, and keeping it off the primary protects write throughput during a traffic peak. Point it at the reader endpoint described in Provisioning Read Replicas for PostGIS Tile Queries.

Do I need a tile cache in front of it?

A CDN, yes — without one every pan and zoom becomes a database query and the service’s cost and capacity are dominated by traffic that a cache would have absorbed. A separate tile cache layer is only worth adding when the CDN’s hit ratio is already high and origin cost is still the constraint.

How do I publish a layer that needs a parameter?

Publish a function rather than a table or view. pg_tileserv exposes functions returning MVT as parameterised layers, which is the supported route for filtering by a client-supplied value — and the same review discipline applies, since a function that interpolates its parameters into SQL is an injection surface on a public endpoint.