Security Group Hardening for Spatial Infrastructure as Code
A single over-broad ingress rule on a PostGIS port or a tile-renderer instance can expose an entire cadastral catalog, and in spatial platforms that mistake usually enters through a console edit that never reaches the declarative pipeline. Security group hardening solves this by treating every network boundary around geospatial workloads as a codified, peer-reviewed artifact rather than a firewall toggle. This guide sits within the broader Network Security & Access Control framework and works hand-in-hand with the routing decisions made in VPC Routing for Tile Servers and the identity scoping defined in IAM Role Mapping for Geospatial Workloads — security groups decide what can reach a port, while routing and IAM decide where traffic flows and who the workload is. Hardening them through Terraform or Pulumi gives map tile caches, OGC endpoints, vector pipelines, and spatial databases the same version control, plan review, and automated validation as application code.
Environment Parity and Configuration Drift Mitigation
The most damaging security group failures are not missing rules — they are rules that exist in production but not in staging, or vice versa. A staging environment that quietly allows 0.0.0.0/0 on port 5432 “to debug a connection issue” becomes the template the next engineer copies into production. Hardening therefore begins with structural parity: a single parameterized module renders every environment, and only typed variables (CIDR lists, port maps, environment name) differ between them.
Parity also depends on the state backend being authoritative. Remote state with mandatory locking — see State Backend Selection — prevents two pipelines from racing on the same aws_security_group and leaving a half-applied ruleset. Treat any out-of-band console change as a security incident: a manual AuthorizeSecurityGroupIngress that opens a management port both widens the attack surface and silently desynchronizes every downstream environment that was supposed to mirror it.
Three parity controls keep geospatial network boundaries reproducible:
- A locked rule matrix per service. Maintain the canonical set of allowed ports as a typed variable —
5432for PostGIS,443for the public tile edge,8080/8443for GeoServer’s internal admin — so a new port cannot appear in one environment without a reviewed change to the shared module. - Named, described rules instead of monolithic blocks. Each ingress/egress rule carries an explicit
description. Aplandiff then reads as “added: PostGIS access from analytics subnet” rather than an opaque list mutation, which makes drift and unintended widening obvious in review. prevent_destroyon core groups. Lifecycle guards on the database and edge security groups stop a careless refactor from deleting and recreating a group, which would momentarily detach every dependent ENI and break tenant isolation during the gap.
CI/CD Validation and Operational Guardrails
Security group changes must be validated before they reach a live data plane, not after. The pull-request pipeline runs terraform plan or pulumi preview and feeds the proposed rules into policy-as-code checks so that an overly permissive change fails the build instead of an audit.
Effective gates for spatial network boundaries include:
- Reject public ingress on data-plane ports. An Open Policy Agent (OPA) or
checkovrule denies any rule that pairs0.0.0.0/0(or::/0) with the PostGIS, Redis, or GeoServer admin ports. Public exposure is permitted only on the explicitly tagged edge group serving tiles and OGC requests. - Require descriptions and source references. Rules whose
cidr_blocksare hard-coded literals rather than references to known subnet or prefix-list variables are flagged, because literals are how stale or accidental ranges creep in. - Diff-scope egress. A change that broadens egress from the VPC CIDR to
0.0.0.0/0is blocked unless explicitly justified, since unrestricted egress is the exfiltration path a compromised tile renderer would use. - Continuous drift detection. A scheduled
plan(for example a nightly pipeline) compares live security groups against state and raises an alert — or auto-reverts — when a console edit has introduced an unmanaged rule.
By making every modification an immutable pipeline artifact, teams replace ad-hoc firewall edits with a repeatable cadence. This is the same gating philosophy applied at the port level in Hardening Security Groups for PostGIS Ports, where ephemeral direct access is prohibited and connections are forced through a pooler.
Resource Architecture and Service Integration
Security groups never harden a workload in isolation; they are one layer of a system that also includes routing, identity, and browser-level policy. The architecture for a spatial platform typically defines distinct groups per tier, each referencing the next by security-group ID rather than by CIDR, so that trust follows the workload even as IP addresses change.
A representative topology for a tiled map service:
- Edge group — attached to the load balancer or CDN origin. Allows
443from the public internet (or from CDN edge prefix lists), and nothing else. This is the only group permitted to face the open internet, and it pairs with the CORS & CSP Configuration controls that constrain which origins may consume the rendered tiles. - Rendering group — attached to the tile servers and GeoServer instances provisioned through GeoServer Deployment Patterns. Accepts traffic only from the edge group’s ID, and originates the read requests that reach the data plane.
- Data-plane group — attached to the PostGIS cluster. Accepts
5432strictly from the rendering group’s ID and from a tightly scoped analytics subnet, never from the internet.
Referencing groups by ID rather than CIDR is what makes this composable with Module Design Patterns: the database module exposes its security-group ID as an output, and the rendering module consumes it as an input, so the dependency graph — not a brittle list of IP ranges — defines who may talk to whom.
Runnable Configuration
The following Terraform module hardens the three tiers above with pinned provider versions, security-group-ID references for east-west trust, and explicit descriptions on every rule. It deliberately avoids inline ingress/egress blocks in favor of standalone aws_vpc_security_group_ingress_rule resources, which produce cleaner plan diffs and let policy checks evaluate one rule at a time.
# modules/network/spatial_security_groups/main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
variable "vpc_id" { type = string }
variable "environment" { type = string }
variable "cdn_edge_prefix_list_id" { type = string } # managed prefix list for CDN origins
variable "analytics_subnet_cidr" { type = string }
# --- Edge tier: only public-facing group ---
resource "aws_security_group" "edge" {
name = "gis-edge-${var.environment}"
description = "Public tile/OGC edge - HTTPS only"
vpc_id = var.vpc_id
tags = { Tier = "edge", Environment = var.environment, ManagedBy = "terraform" }
lifecycle { prevent_destroy = true } # guard against detach-on-recreate
}
resource "aws_vpc_security_group_ingress_rule" "edge_https" {
security_group_id = aws_security_group.edge.id
description = "HTTPS tile/OGC requests from CDN edge prefix list"
ip_protocol = "tcp"
from_port = 443
to_port = 443
prefix_list_id = var.cdn_edge_prefix_list_id # not 0.0.0.0/0
}
# --- Rendering tier: tile servers / GeoServer ---
resource "aws_security_group" "rendering" {
name = "gis-rendering-${var.environment}"
description = "Tile renderers and GeoServer - edge ingress only"
vpc_id = var.vpc_id
tags = { Tier = "rendering", Environment = var.environment, ManagedBy = "terraform" }
}
resource "aws_vpc_security_group_ingress_rule" "rendering_from_edge" {
security_group_id = aws_security_group.rendering.id
description = "Tile requests from the edge group only"
ip_protocol = "tcp"
from_port = 8080
to_port = 8080
referenced_security_group_id = aws_security_group.edge.id # trust follows the workload
}
# --- Data plane: PostGIS ---
resource "aws_security_group" "data_plane" {
name = "gis-postgis-${var.environment}"
description = "PostGIS - rendering + analytics only, never public"
vpc_id = var.vpc_id
tags = { Tier = "data", Environment = var.environment, ManagedBy = "terraform" }
lifecycle { prevent_destroy = true }
}
resource "aws_vpc_security_group_ingress_rule" "postgis_from_rendering" {
security_group_id = aws_security_group.data_plane.id
description = "PostGIS 5432 from rendering tier"
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
referenced_security_group_id = aws_security_group.rendering.id
}
resource "aws_vpc_security_group_ingress_rule" "postgis_from_analytics" {
security_group_id = aws_security_group.data_plane.id
description = "PostGIS 5432 from scoped analytics subnet"
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
cidr_ipv4 = var.analytics_subnet_cidr
}
# Scoped egress: data plane reaches object storage via gateway endpoint route,
# so outbound stays inside the VPC CIDR rather than 0.0.0.0/0.
resource "aws_vpc_security_group_egress_rule" "data_plane_internal" {
security_group_id = aws_security_group.data_plane.id
description = "Outbound to internal routing layer only"
ip_protocol = "-1"
cidr_ipv4 = data.aws_vpc.this.cidr_block
}
data "aws_vpc" "this" { id = var.vpc_id }
output "data_plane_sg_id" { value = aws_security_group.data_plane.id }
The equivalent Pulumi program pins its provider in package.json and mirrors the same ID-referencing trust chain:
// index.ts — pin "@pulumi/aws": "6.x" in package.json
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const cfg = new pulumi.Config();
const env = cfg.require("environment");
const vpcId = cfg.require("vpcId");
const edge = new aws.ec2.SecurityGroup("gis-edge", {
vpcId,
description: "Public tile/OGC edge - HTTPS only",
ingress: [{
description: "HTTPS from CDN edge prefix list",
protocol: "tcp", fromPort: 443, toPort: 443,
prefixListIds: [cfg.require("cdnEdgePrefixListId")], // not 0.0.0.0/0
}],
tags: { Tier: "edge", Environment: env, ManagedBy: "pulumi" },
});
const dataPlane = new aws.ec2.SecurityGroup("gis-postgis", {
vpcId,
description: "PostGIS - rendering tier only, never public",
ingress: [{
description: "PostGIS 5432 from rendering tier",
protocol: "tcp", fromPort: 5432, toPort: 5432,
securityGroups: [edge.id], // reference by SG id, resolved as a pulumi.Output
}],
tags: { Tier: "data", Environment: env, ManagedBy: "pulumi" },
});
export const dataPlaneSgId = dataPlane.id;
Guardrails Embedded in the Configuration
The configuration above is hardened by design rather than by external review, because the constraints live in the code:
- State locking is non-negotiable. Both implementations assume a remote backend with locking so concurrent applies cannot leave a security group in a partially authorized state. A lost lock during a rule rewrite is how a database briefly accepts wider traffic than intended.
- No secrets in network code. Security group definitions carry only ports, descriptions, and references — never credentials. Database passwords and connection strings stay in a secrets manager and are injected at runtime, keeping the network module safe to render in plan output and PR comments.
- Network isolation is expressed as references, not ranges. Using
referenced_security_group_id(Terraform) andsecurityGroups: [edge.id](Pulumi) means trust is anchored to a workload identity. Re-IP an instance, scale the rendering tier, or rebuild the edge — the rule still resolves correctly and the data plane is never accidentally opened to a stale CIDR. - Egress is scoped to the VPC. Outbound from the data plane is restricted to the VPC CIDR so that PostGIS reads of Cloud Optimized GeoTIFFs travel through a gateway VPC endpoint route rather than a NAT path to the open internet — closing the obvious exfiltration channel and avoiding metered egress on raster I/O.
prevent_destroyprotects continuity. Lifecycle guards on the edge and data-plane groups ensure a refactor cannot delete-and-recreate a group, which would detach every dependent ENI and drop tenant isolation during the window.
Troubleshooting and Failure Modes
1. Circular security-group reference on first apply. When the edge group references the rendering group and vice versa, a single-pass apply can fail with a dependency cycle. Break it by defining the groups first and attaching the cross-referencing rules as standalone aws_vpc_security_group_ingress_rule resources (as shown above), so the group IDs exist before any rule resolves them.
2. Rule-count limit exhaustion. AWS caps rules per security group and groups per ENI. A platform that adds a per-tenant CIDR rule for every customer hits the limit and new applys fail with RulesPerSecurityGroupLimitExceeded. The fix is a managed prefix list referenced once, rather than one rule per range — collapse the tenant CIDRs into the prefix list and reference it.
3. Silent egress conversion to metered NAT. If the data-plane egress is left at 0.0.0.0/0 and no gateway VPC endpoint route exists, COG range reads against object storage route through the NAT gateway. There is no error — only latency and a rising data-transfer bill. Verify the endpoint route table association and keep egress scoped to the VPC CIDR.
4. Drift from console hotfixes. An engineer adds a temporary ingress rule in the console during an incident and never removes it. The nightly drift plan surfaces it as an unmanaged rule; without that scheduled check the rule persists indefinitely and parity is broken across environments. Treat the drift alert as the trigger to either codify or revert.
5. Default security group left wide open. Newly created VPCs ship a default security group that permits all intra-group traffic. Instances launched without an explicit group inherit it, bypassing the tiered design entirely. Codify an empty-rule default group (deny everything) and assert in policy that no spatial workload is attached to it.
Related
- Network Security & Access Control — the parent framework for boundary, identity, and edge controls
- VPC Routing for Tile Servers — routing decisions that pair with these security groups
- IAM Role Mapping for Geospatial Workloads — workload identity scoping behind the network layer
- CORS & CSP Configuration — browser-level policy for the public tile edge
- Hardening Security Groups for PostGIS Ports — port-level deep dive for the data plane