TerraformXXIII · Policy as CodeProduction Terraform
Network Exposure Policies
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
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.
- 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. - Per-organisation superblock. The org owns
10.0.0.0/8. The first octet is fixed at10. - Per-region sub-allocation.
10.0.0.0/10is theus-east-2superblock;10.64.0.0/10iseu-west-2;10.128.0.0/10isus-east-1. A workspace ineu-west-2must allocate from10.64.0.0/10. - Per-workspace block. Each workspace gets a /16 or a /18, allocated from the region’s superblock.
- Per-AZ subnets. Each subnet is /24 (256 addresses) or /25 (128). Subnets of /22 or larger are an antipattern — they over-allocate.
- 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:
- 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 rejectscidr_blockoutside the table. - Security group rule that opens everything. A rule
declares
cidr_blocks = ["0.0.0.0/0"]andfrom_port = 0,to_port = 65535. The plan-time policy catches it. Mitigation: the rule is well-known and reviewed. - Subnet oversize. A subnet is declared with
cidr_block = "10.0.0.0/16"where/24would have been correct. The wasted address space compounds. The policy asserts subnet size is between/25and/22for production workspaces. - 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.
- 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/0carriestags.allow_public_ingress = trueand 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
Q1. What is the production-default posture for ingress to a security group on port 22?
Q2. Where should the canonical VPC / subnet pattern live?
Q3. CIDR overlap between two VPCs is a noisy failure: the apply does not succeed.
Q4. What is the correct cadence for an allocation-table diff?
Q5. Which rules should a mature security-group rule set enforce? (Select all that apply.)
Q6. An egress rule from a data subnet is misrouted to the NAT gateway instead of the transit gateway. What does the policy catch?
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.