Skip to main content
RunBook Academy

TerraformXXIII · Policy as CodeProduction Terraform

Network Exposure Policies

Intermediate⏱ ~14 minbash

What you'll learn

  • Codify the canonical VPC / subnet pattern in a module
  • Enforce CIDR discipline: non-overlapping ranges, RFC1918 boundaries, allocation table
  • Author security group rules as policy: deny-by-default ingress, no 0.0.0.0/0
  • Schedule the network audit cadence that catches drift and overlap

Prerequisites

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A network policy that is not codified in the same repository as the resources it constrains is a network policy that drifts on the first day after it ships. The most common shape of “we have a network policy” in 2026 is a Confluence page that no engineer has read since publishing. The job of the policy is to be a module that produces both the topology and the constraints; the job of the engineer is to call the module.

What network policy as code means

A network policy as code is not a separate document. It is a shape:

+-----------------------------------+
|  Network module (versioned, internal) |
|  - VPC + subnets pattern           |
|  - Routing tables                  |
|  - NAT gateways                    |
|  - Security group helpers          |
|  - CIDR allow-list argument        |
+-----------------------------------+
                  ^
                  |  every workspace instantiates this
                  |  module with non-overlapping CIDRs
                  |
+----------+----------+----------+
|          |          |          |
| prod     | staging  | sandbox  |
| us-east  | us-east  | us-east  |
+----------+----------+----------+

The module owns the topology. The workspaces feed CIDR blocks in. A change to the module is a code change with a review path. The policy is the module’s argument shape: the only valid CIDRs are in an allow list; the only valid security-group rules are in the helpers.

The canonical VPC / subnet pattern

Most production estates use one of three patterns. The pattern is codified once and instantiated everywhere.

Three-tier pattern. Public subnets host the load balancers; private subnets host the application; isolated subnets host data stores.

module "vpc_three_tier" {
  source  = "acme.invalid/network/vpc-three-tier"
  version = "~> 4.0"

  cidr_block            = "10.42.0.0/16"
  region                = "eu-west-2"
  public_subnet_cidrs   = ["10.42.0.0/24", "10.42.1.0/24", "10.42.2.0/24"]
  private_subnet_cidrs  = ["10.42.10.0/24", "10.42.11.0/24", "10.42.12.0/24"]
  data_subnet_cidrs     = ["10.42.20.0/24", "10.42.21.0/24", "10.42.22.0/24"]

  azs = ["eu-west-2a", "eu-west-2b", "eu-west-2c"]

  tags = {
    Owner       = "platform-eng@example.com"
    Environment = var.environment
    CostCentre  = "CC-1042"
    Project     = "PROJ-181"
    ManagedBy   = "terraform"
  }
}

The module exposes the right variables and hides the rest (route tables, NACLs, NAT configuration). The workspace authors do not declare a aws_route_table resource; they declare the module. If the module requires a variable that they have not provided, the apply fails.

Hub-and-spoke. A central VPC peers with spoke VPCs. The hub contains shared services (Transit Gateway, DNS resolver, packet inspection). The spokes contain workloads. The module codifies the peering and the route propagation.

module "spoke_vpc" {
  source  = "acme.invalid/network/spoke-vpc"
  version = "~> 2.0"

  cidr_block           = "10.50.4.0/22"
  hub_vpc_id           = module.platform_hub.vpc_id
  transit_gateway_id   = module.platform_hub.transit_gateway_id
  shared_services_cidr = "10.10.0.0/16"

  tags = local.required_tags
}

Single VPC, single account. A simpler pattern, often the right choice for a small estate. The same module’s single-tier variant produces a VPC with one public and one private subnet, no transit gateway. The full pattern catalogue is a module family; the right one is picked at workspace creation.

CIDR discipline

The most common production-grade network incident is overlapping CIDRs. Two VPCs that share a /16 are peered or routed; the routing tables disagree; an instance in the first VPC cannot reach a service in the second because the routing table picks the wrong VTEP. The fix is discipline at planning time.

Rules.

  1. RFC1918 only. The internal estate lives inside 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. Public CIDRs are not allocated to internal resources.
  2. Per-organisation superblock. The org owns 10.0.0.0/8. The first octet is fixed at 10.
  3. Per-region sub-allocation. 10.0.0.0/10 is the us-east-2 superblock; 10.64.0.0/10 is eu-west-2; 10.128.0.0/10 is us-east-1. A workspace in eu-west-2 must allocate from 10.64.0.0/10.
  4. Per-workspace block. Each workspace gets a /16 or a /18, allocated from the region’s superblock.
  5. Per-AZ subnets. Each subnet is /24 (256 addresses) or /25 (128). Subnets of /22 or larger are an antipattern — they over-allocate.
  6. No nesting collisions. VPCs peer; transit gateways route; the routing tables agree on the destination. The allocation table is the source of truth.

The allocation table is not in code; it is in a checked-in file:

Region      Workspace        CIDR              Purpose
-----------------------------------------------------------------------
us-east-2   prod-platform    10.0.0.0/16       Primary production
us-east-2   prod-data        10.1.0.0/16       Data tier
eu-west-2   prod-platform    10.64.0.0/16      EU production
eu-west-2   prod-data        10.65.0.0/16      EU data
us-east-1   prod-platform    10.128.0.0/16     US east production

The file is in the network module’s repository. The policy is: a workspace’s declared cidr_block must be in the allocation table.

Security group rules as code

Security groups are the production-unit firewall. The policy is harder than the VPC pattern: the topology is fixed once, but the rules must adapt to the workload without leaking to the internet.

Rule 1. Deny 0.0.0.0/0 ingress. The single most violated rule in production. SSH or RDP opened to the world is a credential-stuffer’s dream.

package terraform.sg_ingress

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "aws_security_group_rule"
  rc.change.after.type == "ingress"
  contains(rc.change.after.cidr_blocks[_], "0.0.0.0/0")
  port := rc.change.after.from_port
  port >= 22
  port <= 22
  msg := sprintf(
    "%s opens port 22 to the internet",
    [rc.address]
  )
}

A more general rule that catches all 0.0.0.0/0 ingress outside a documented exception:

exception_prefixes := ["acme.invalid/security-groups/exceptions"]

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "aws_security_group_rule"
  rc.change.after.type == "ingress"
  contains(rc.change.after.cidr_blocks[_], "0.0.0.0/0")
  not rc.change.after.tags.allow_public_ingress
  msg := sprintf(
    "%s allows 0.0.0.0/0 ingress and is missing allow_public_ingress tag",
    [rc.address]
  )
}

The allow_public_ingress = true tag is itself the exception. The exception is reviewed in the module.

Rule 2. Stateful egress only. Egress from data subnets is restricted to the subnets it must reach. Egress to the internet is allowed only through the NAT gateway and firewall.

Rule 3. Same-security-group ingress. Internal services that face each other are members of the same security group or refer to the other security group directly. Reference-by-name, not reference-by-IP.

resource "aws_security_group_rule" "app_to_db" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.app.id
  security_group_id        = aws_security_group.db.id
}

Rule 4. The smallest source range that works. A rule that allows 10.0.0.0/8 is wider than the workload needs. The narrower the source, the smaller the blast radius of a compromised source.

The audit cadence

Frequency   Check
-----------------------------------------------------------------------
Per-run     Sentinel / OPA policy runs the security-group rules
            against the plan.

Daily       AWS Config / Network Insights check on
            intentionally_open_security_groups. Alert on
            new findings.

Weekly      Allocation table diff. Compare the
            declared CIDRs in the network module repository
            against the live VPCs. Drift between
            declaration and reality is flagged.

Monthly     Transit gateway route table review. Compare
            the declared routes (peering topology) against
            the actual route tables.

Quarterly   Network policy itself. The pattern, the
            CIDR allow list, the security-group
            exceptions. A quarterly review catches
            gratuitous exceptions.

Annually    Architecture review. VPC count, peer count,
            subnet count, route count. A simple count
            over time catches the kind of organic growth
            that produces an unmaintainable network.

Validating the policy

conftest test plan.json --policy policy/network.rego --output json
{
  "passed": 5,
  "failed": 0,
  "warnings": 0,
  "filename": "plan.json"
}

The five tests are:

- vpc-cidr-allocated
- subnet-size-correct
- sg-no-public-ingress
- sg-egress-restricted
- routes-from-allow-list

The last test catches the case where a workspace adds a route table that points to a destination outside the allocation table.

For the allocation table itself:

python3 scripts/allocation_diff.py
PLAN: prod-platform-eu-west-2 declared 10.64.0.0/16
LIVE: 10.64.0.0/16 vpc-aaaa
OK: 1 declared, 1 live

A diff that produces a MISSING line or an UNEXPECTED line is a network-drift finding.

Failure modes

Five failure modes to recognise in production:

  1. CIDR overlap between two VPCs. A new workspace is created with cidr_block = "10.0.0.0/16"; an older workspace already owns it. The apply succeeds; the peering (or transit gateway) routes to the wrong VPC. Mitigation: the allocation table is the source of truth; the policy rejects cidr_block outside the table.
  2. Security group rule that opens everything. A rule declares cidr_blocks = ["0.0.0.0/0"] and from_port = 0, to_port = 65535. The plan-time policy catches it. Mitigation: the rule is well-known and reviewed.
  3. Subnet oversize. A subnet is declared with cidr_block = "10.0.0.0/16" where /24 would have been correct. The wasted address space compounds. The policy asserts subnet size is between /25 and /22 for production workspaces.
  4. Egress to the internet from a data subnet. A data subnet has a route to the NAT gateway. Production databases should not have a route to the internet. The policy asserts the destination of the data subnet route is the transit gateway, not the NAT.
  5. Drift between declared and live. An engineer adds a route table rule via the console during an incident. The plan says no change. The allocation table diff catches the rule the next time it runs.

Security and performance

The network policy has a small operational cost. The plan-time rules add a few seconds to the apply; the static checks are negligible. The audits (allocation diff, route table review) are cheap scripts in CI.

The security benefit is large. A codified network with a module that is the only allowed way to create a VPC, plus a plan-time policy that catches 0.0.0.0/0 ingress, removes a class of incident that an earlier generation of sysadmins spent years firefighting.

The performance cost is on egress: the NAT gateway is a bottleneck; the transit gateway adds latency. These are topology choices, not policy choices. The policy enforces the choices, not the performance.

Production guidance

A few operational points:

  • One module, many versions. A team that needs a new pattern should add a new module, not modify the old one. The module interface is the contract; modifying a module is a breaking change for everyone who uses it.
  • The allocation table is in version control. The table is not a Confluence page; it is a checked-in file in the network module’s repository. PRs to the table are reviewed by the platform team.
  • Exceptions to the security-group rules are tagged. A rule that needs 0.0.0.0/0 carries tags.allow_public_ingress = true and a ticket reference in the description. The tag is self-documenting and self-removing.
  • The audit cadence is automated. The allocation diff, the AWS Config rule, and the route table comparison run daily. The output is a Slack alert and a tracking ticket, not a manual eye.

What comes next

The network policy closes the last of the codified controls. The next lesson in this part pulls these controls together into the operational routine — the day-to-day review process that keeps the policy set alive.

Verification

conftest test plan.json --policy policy/network.rego --output json
{
  "passed": 5,
  "failed": 0,
  "filename": "plan.json"
}

For the security-group check, run a representative sample of the cloud’s security groups through the same rule:

aws ec2 describe-security-groups \
  --query 'SecurityGroups[*].{Id:GroupId,Ingress:IpPermissions}' \
  --output json | conftest test -
FAIL - - aws_security_group_rule.sg_app_to_db opens port 22 to the internet
5 tests, 4 passed, 1 failed

The remediation is to update the security group’s ingress to a narrow CIDR. The next run passes.

Knowledge check · 7 questions

  1. Q1. What is the production-default posture for ingress to a security group on port 22?

  2. Q2. Where should the canonical VPC / subnet pattern live?

  3. Q3. CIDR overlap between two VPCs is a noisy failure: the apply does not succeed.

  4. Q4. What is the correct cadence for an allocation-table diff?

  5. Q5. Which rules should a mature security-group rule set enforce? (Select all that apply.)

  6. Q6. An egress rule from a data subnet is misrouted to the NAT gateway instead of the transit gateway. What does the policy catch?

  7. Q7. A workspace is created with cidr_block = 10.0.0.0/16, but the allocation table lists 10.0.0.0/16 as already assigned to a different workspace. What is the right policy behaviour?

Passing score: 75%. Answers are checked in this browser.