PrivateLink Endpoints for PostGIS Access

A tile renderer in one account needs the parcels database in another. The obvious route is VPC peering, and it works — at the cost of joining two routing domains, requiring non-overlapping address space, and giving every workload in each VPC a network path to every workload in the other. PrivateLink offers a narrower alternative: expose one service, consume it as a single endpoint, and share no routing at all. For a shared spatial database consumed by several teams or several accounts, that difference is the whole point. This guide extends VPC Routing for Tile Servers within Network Security and Access Control.

Peering and PrivateLink solve different problems and the choice is usually clear once framed correctly.

Peering joins networks. Both VPCs’ address spaces become mutually routable, subject to route tables and security groups. It is the right choice when two environments genuinely need broad mutual access — a renderer fleet and a database it effectively owns, in the same organisational boundary. It requires non-overlapping CIDRs, and its blast radius is a network, not a service.

PrivateLink exposes a service. The consumer gets an endpoint in its own subnets that resolves to one service in the provider’s VPC. Traffic is unidirectional by construction: the consumer can reach the service, and the service cannot reach back into the consumer. Address spaces may overlap freely, because no routing is shared. It is the right choice when the relationship is “several consumers use one database” rather than “two networks are one network”.

The cost difference matters at spatial-workload volumes. PrivateLink charges per endpoint-hour and per gigabyte processed; peering charges only for cross-zone or cross-region transfer. A renderer pulling large geometry result sets continuously will pay noticeably more through an endpoint, and that should be modelled rather than discovered — the approach in Cost Estimation Frameworks applies directly.

Peering joins networks; PrivateLink exposes one service With peering, the consumer and provider VPCs become mutually routable subject to route tables and security groups, so in principle every workload in each network can reach the other, and the two address spaces must not overlap. With PrivateLink, the consumer places an endpoint in its own subnets which resolves to a single service behind a network load balancer in the provider VPC. Traffic flows only from consumer to service; the provider cannot initiate a connection back. No routes are exchanged, so the two address spaces may overlap freely, and the unit of exposure is one service rather than one network. VPC peering Consumer VPC 10.1.0.0/16 both ways Provider VPC 10.2.0.0/16 CIDRs must not overlap exposure is a network, not a service PrivateLink Consumer VPC 10.1.0.0/16 Endpoint in consumer subnets Endpoint service NLB → PostGIS one direction only CIDRs may overlap exposure is one service Endpoints charge per hour and per gigabyte processed — model that before a renderer pulls geometry through one all day.

Prerequisites and environment assumptions

Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A PostGIS cluster in the provider account, reachable from a network load balancer in the same VPC — PrivateLink fronts a network load balancer, not a database directly, so the NLB with an IP target group pointed at the database endpoint is a required intermediate. Consumer-side subnets in at least two availability zones, since an endpoint is provisioned per zone and a single-zone endpoint is a single point of failure for a database connection.

Be aware of one behaviour that surprises everyone the first time: the load balancer sees the endpoint’s network interface as the source, so the database sees connections from the load balancer, not from the consumer. Source-based authorisation therefore does not distinguish consumers, and per-consumer authorisation must happen at the database — separate roles per consumer — or at the endpoint service’s allowlist.

Step-by-step implementation

  1. Front the database with a network load balancer. Internal, TCP on 5432, with an IP target group holding the database endpoint’s addresses. Health-check the port, and be aware the addresses behind a managed database endpoint can change on failover, which is why a periodic reconciliation of the target group is worth automating.

  2. Create the endpoint service and set acceptance to manual. Automatic acceptance means anyone who learns the service name and is in the allowlist connects without a human step. Manual acceptance makes each new consumer a deliberate decision, which for a database holding authoritative geometry is the right default.

  3. Allowlist the consuming principals explicitly. The endpoint service permission list names which accounts or roles may even request a connection. Combined with manual acceptance this is two independent gates.

  4. Create the endpoint in the consumer VPC, in every zone the workload runs in. Attach a security group permitting 5432 from the renderer’s security group only, and enable private DNS so the consumer’s application uses a stable hostname rather than the endpoint’s generated one.

  5. Give each consumer its own database role. Because the database cannot distinguish consumers by source address through the endpoint, the identity must come from the credential. One role per consumer, read-only, sourced from a secrets manager as described in Secrets Management for Spatial Pipelines.

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

# ---- Provider side ----

resource "aws_lb" "postgis" {
  name               = "postgis-privatelink"
  internal           = true
  load_balancer_type = "network"
  subnets            = var.provider_subnet_ids
  enable_cross_zone_load_balancing = true
}

resource "aws_lb_target_group" "postgis" {
  name        = "postgis-tg"
  port        = 5432
  protocol    = "TCP"
  vpc_id      = var.provider_vpc_id
  # IP targets: a managed database is reached by address, and those addresses
  # can change on failover — reconcile this target group periodically.
  target_type = "ip"

  health_check {
    protocol            = "TCP"
    port                = "5432"
    interval            = 10
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

resource "aws_vpc_endpoint_service" "postgis" {
  acceptance_required        = true # a human step per consumer, deliberately
  network_load_balancer_arns = [aws_lb.postgis.arn]

  tags = {
    Service = "postgis-read"
    Owner   = "geospatial-platform"
  }
}

# Two independent gates: the allowlist decides who may ask, manual acceptance
# decides whether they are connected.
resource "aws_vpc_endpoint_service_allowed_principal" "tile_team" {
  vpc_endpoint_service_id = aws_vpc_endpoint_service.postgis.id
  principal_arn           = var.tile_team_account_arn
}

# ---- Consumer side ----

resource "aws_vpc_endpoint" "postgis" {
  vpc_id            = var.consumer_vpc_id
  service_name      = aws_vpc_endpoint_service.postgis.service_name
  vpc_endpoint_type = "Interface"
  # One endpoint network interface per zone: a single-zone endpoint is a single
  # point of failure for every database connection the renderer makes.
  subnet_ids          = var.consumer_subnet_ids
  security_group_ids  = [aws_security_group.postgis_endpoint.id]
  private_dns_enabled = true
}

resource "aws_security_group" "postgis_endpoint" {
  name   = "postgis-endpoint"
  vpc_id = var.consumer_vpc_id

  ingress {
    description     = "PostGIS 5432 from the renderer fleet only"
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [var.renderer_sg_id]
  }
}

variable "provider_vpc_id" { type = string }
variable "provider_subnet_ids" { type = list(string) }
variable "consumer_vpc_id" { type = string }
variable "consumer_subnet_ids" { type = list(string) }
variable "tile_team_account_arn" { type = string }
variable "renderer_sg_id" { type = string }
The database cannot tell consumers apart by source address Two consumer accounts each connect through their own endpoint. Both paths converge on the provider's network load balancer, and the database sees connections originating from the load balancer's own addresses in both cases. Source-based authorisation at the database therefore cannot distinguish one consumer from another, and a rule permitting the load balancer permits every consumer equally. Per-consumer authorisation must instead come from the credential: one database role per consumer, each read-only and scoped to the schemas that consumer is entitled to, with the credentials issued and rotated through a secrets manager. Consumer A tile renderers Consumer B analytics jobs Load balancer its addresses, not theirs PostGIS sees one source for both consumers So identity comes from the credential one read-only role per consumer, issued from the secrets manager

Verification

From a renderer task in the consumer VPC, resolve the private DNS name and confirm it returns an address inside the consumer’s own subnets — that is the signal that private DNS is working and that traffic is not leaving through some other path. Then connect with psql using sslmode=verify-full and run SELECT postgis_full_version(). Verification of the certificate matters here specifically because the endpoint introduces an intermediary, and require alone would accept any endpoint that answers.

Confirm the direction of the relationship by attempting a connection from the provider VPC into a consumer service; it must fail, because PrivateLink is unidirectional and that property is a large part of why it was chosen. Confirm zone coverage by checking there is an endpoint network interface in every subnet the renderer runs in. Finally check the endpoint’s processed-bytes metric after a day of real traffic and compare it against the cost model, since this is the number that decides whether the architecture stays affordable.

Four checks that the endpoint is doing what it was chosen for Resolving the private DNS name from a consumer workload must return an address inside the consumer's own subnets, which confirms traffic is not leaving by some other path. Connecting with full certificate verification and running a spatial query confirms the whole chain works, and verification matters here because the endpoint introduces an intermediary. Attempting a connection in the opposite direction, from the provider network into a consumer service, must fail — the link is unidirectional by construction and that property is much of why it was chosen. And an endpoint network interface must exist in every availability zone the workload runs in, or one zone's tasks have no path to the database at all. private DNS resolves inside the consumer subnets traffic is not leaving another way verify-full connection answers a spatial query an intermediary makes verification matter more provider to consumer connection fails one-way by construction — much of the point an interface in every zone the workload runs in or one zone has no path at all

Preventing recurrence

  • Automate target-group reconciliation. A managed database’s addresses can change on failover, and a stale IP target group produces a database that is up and unreachable.
  • Keep acceptance manual and review the allowlist quarterly. A consumer that no longer exists should not retain a connection path to authoritative geometry.
  • Alarm on endpoint processed bytes. It is both a cost signal and a behavioural one — a sudden rise usually means a consumer started pulling far more geometry than it used to.
  • Give every consumer its own role and rotate on the same schedule. Since the network cannot distinguish consumers, the credential is the only boundary, and a shared credential collapses it entirely.

Frequently Asked Questions

Can I use PrivateLink to reach a managed database directly?

Not directly — it fronts a network load balancer, so an internal NLB with an IP target group pointed at the database endpoint is a required intermediate. That indirection is also why the database sees the load balancer as the source rather than the consumer.

Is PrivateLink cheaper than peering?

Usually not, for a high-volume database connection. Peering charges only cross-zone or cross-region transfer, while an endpoint charges per hour and per gigabyte processed. PrivateLink is chosen for its isolation properties, and the cost should be modelled deliberately rather than assumed to be neutral.

Does the consumer's traffic ever leave the private network?

No. Endpoint traffic stays on the provider network and never traverses the public internet, which is the security argument for the architecture. Encrypt it anyway with verify-full, because the intermediary makes server verification more important, not less.

What happens on a database failover?

The endpoint is unaffected, but the target group may be pointing at addresses that no longer serve the primary. Automating target reconciliation, or using a target that follows the endpoint rather than an address, is what keeps a failover from becoming an outage on the consumer side.