Build the entire network layer the AI platform runs inside — in alert mode, so nothing gets blocked yet.
This is the biggest block in the substrate by resource count (82 resources), cost (~$425/month standing), and operational consequence (the first block where a mistake could affect traffic, not just metadata). It establishes three trust-zone subnets (agent / lockbox / egress), the single egress chokepoint (AWS Network Firewall), the private path to AWS services (13 VPC endpoints), and the dual L4/L7 egress defense (NFW + DNS Firewall). Both firewalls land in alert mode: they observe and log all traffic, drop nothing. Enforcement is deferred to Step 5, after real agent workloads from Step 4 are exercising the network and observability is live to monitor the flip.
If skipped: No VPC for any AWS workload to run inside. No firewall surface to defend egress. No private path to AWS services (everything goes over public internet via NAT). No flow logs, no DNS observability. Every subsequent block (data, observability, lambda-tools, runtime) cannot deploy because it has nowhere to attach.
Bootstrap (Step 1) created the substrate. Identity (Step 2) created the trust zones at the cryptographic and IAM layers. Network (Step 3) creates the trust zones at the physical layer — the actual VPC, subnets, route tables, and firewall surfaces that workloads live in. After this step, every downstream resource (data, observability, lambda-tools, runtime) has somewhere to attach.
Network is where the platform's egress doctrine becomes structural. Three trust-zone subnets (agent / lockbox / egress) with explicit route table policies make "lockbox cannot reach the internet" a property of the routing layer, not just IAM. The Network Firewall makes "agents can only egress to the 4 SoRs" a property of the data plane, not just policy.
The network surface is where AWS gives you the most knobs — subnet topology, route tables, security groups, NACLs, VPC endpoints, NFW rule groups, DNS Firewall, flow logs, log destinations, encryption keys. Each is a small decision. The 82-resource count reflects how many distinct decisions are encoded in the design, not gratuitous complexity. The Network Module Reference document (_docs/Network Module Reference.html) walks the topology in detail.
| Layer | Resources | Count | Purpose |
|---|---|---|---|
| Core VPC | VPC + IGW + NAT GW + EIP | 4 | The VPC itself (10.0.0.0/16), the internet attachment, source-NAT for egress traffic, the elastic IP NAT uses |
| Subnets | 9 subnets across 3 AZs × 3 tiers | 9 | agent-private (/22) × 3, lockbox-private (/24) × 3, egress (/24) × 3 |
| Routing | Route tables + associations + routes | 24 | One route table per subnet (9 + 9 associations), plus 6 explicit routes (defaults, NFW endpoint forwarding) |
| Security groups | 4 SGs + 8 rules + default SG lockdown | 12 | agent-sg, lockbox-sg, endpoint-sg, egress-sg, plus their ingress/egress rules, plus the default SG locked down to no rules |
| VPC endpoints | 13 endpoints | 13 | 2 gateway (S3, DynamoDB, free) + 11 interface (Bedrock×4, KMS, Secrets, STS, ECR API/DKR, Logs, Lambda) |
| Network Firewall | NFW + policy + 2 rule groups + logging config | 5 | The firewall resource, its policy, stateless drop-bad rules, stateful FQDN allowlist rules, and the logging destination |
| DNS Firewall | Rule group + association + 2 rules + 2 domain lists | 6 | The DNS-level second layer of egress defense, with the 4-SoR allowlist + catch-all alert |
| DNS query logging | Query log config + association | 2 | Captures every DNS query for audit |
| Flow logs | Flow log + 4 CloudWatch log groups + IAM role + inline policy | 7 | VPC-wide flow log delivery to CloudWatch, encrypted with the audit CMK from Step 2 |
| Total | 82 |
All resources are tagged with Project=ai-sandbox, Owner=platform-team, ManagedBy=terraform, Module=network.
The doctrine: every subnet in the VPC belongs to exactly one of three trust zones, each with a distinct purpose and a distinct internet posture.
| Zone | CIDRs (3 AZs) | Workloads | Default route | Egress to internet |
|---|---|---|---|---|
| agent-private | 10.0.0.0/2210.0.4.0/2210.0.8.0/22 |
AgentCore runtime, Lambda tools (future blocks) | 0.0.0.0/0 → NFW endpoint |
Allowed via NFW → NAT → IGW (4 SoRs only in enforce mode) |
| lockbox-private | 10.0.16.0/2410.0.17.0/2410.0.18.0/24 |
Lockbox reveal Lambda, DDB token table (future) | (no default route) | Impossible by construction. No IGW route exists in any subnet here. |
| egress | 10.0.32.0/24 (single AZ at POC; 3 AZs in multi) |
NFW endpoint, NAT GW (no agent workloads) | 0.0.0.0/0 → IGW |
The only subnet with a direct IGW route. NFW post-inspection traffic flows through here. |
agent ENI → agent-sg (443 outbound only) → agent-private route table
→ NFW endpoint in egress subnet
→ (TLS SNI inspected against 4-SoR allowlist)
→ NAT Gateway in egress subnet
→ (source-NAT to NAT's EIP)
→ IGW
→ SoR API (Salesforce / Monday / GitHub / GitBook)
agent or lockbox ENI → VPC interface endpoint (in same subnet, private DNS)
→ AWS service (Bedrock / KMS / Secrets / STS / Logs / ECR)
AWS API traffic never traverses NAT, never traverses IGW, never hits NFW. Cheaper, faster, more secure, and keeps the NFW allowlist focused on the 4 third-party SoRs.
Both the Network Firewall and the Route53 DNS Firewall land in alert mode. This is the safe state.
| Aspect | Alert mode (this step) | Drop mode (Step 5) |
|---|---|---|
| Stateful rule group action | Logs match, allows traffic | Logs match, drops traffic |
| What an agent calling httpbin.org sees | Call succeeds; alert log entry created | Call hangs / times out; alert log entry created |
| What an agent calling api.salesforce.com sees | Call succeeds; alert log entry created | Call succeeds; no alert (allowlisted) |
| Operational risk | None — nothing is blocked | Real — a missed allowlist entry breaks a workload |
Same pattern: ALERT logs DNS queries that match the catch-all rule but resolves them anyway. BLOCK responds with NXDOMAIN.
terraform.tfvars)| Item | Monthly | Note |
|---|---|---|
| NFW endpoint (1 AZ) | ~$284 | $0.395/hr × 1 AZ × 720h |
| NAT Gateway (1 AZ) | ~$32 | $0.045/hr × 1 AZ × 720h |
| Interface endpoints (11) | ~$79 | $0.01/hr per AZ × 11 endpoints × 1 AZ |
| DNS Firewall + log ingest | ~$30 | ~$0.60/M queries + CloudWatch ingest |
| Flow logs ingest | ~$5-10 | POC traffic volume; ~$0.50/GB ingested |
| Total baseline (single-AZ POC) | ~$425 |
When the platform graduates from POC to supporting internal apps (PayCargo's own SaaS-replacement workloads), the next step is two-AZ resilience — not full three-AZ production. Two AZs adds the cheapest meaningful failover (an AZ-a outage no longer breaks egress); three AZs adds marginal additional resilience at meaningful additional cost and is reserved for true production posture later.
| Tier | Use case | NFW | NAT | Endpoints (11 ×) | Baseline |
|---|---|---|---|---|---|
| 1 AZ (POC, current) | Sandbox, prototyping | $284 | $32 | $79 | ~$425/mo |
| 2 AZ (internal app / SaaS replacement) | Replacing existing internal SaaS tools with AI-powered alternatives; not customer-facing yet | $569 | $65 | $158 | ~$822/mo |
| 3 AZ (full production, future) | Customer-facing production, regulated workloads | $853 | $97 | $237 | ~$1,217/mo |
A two-AZ deployment survives an AZ outage by routing traffic through the surviving AZ. The trade-off is that cross-AZ data charges ($0.01/GB) apply for workloads in AZ-c calling endpoints in AZ-a or AZ-b. At any reasonable sandbox or internal-app traffic volume, this is negligible (sub-$10/month). At sustained high-traffic production, it becomes a third-AZ argument.
The single-line change in terraform.tfvars: az_redundancy = "dual". Terraform plan will show ~22 resources to add (one NFW endpoint, one NAT + EIP, 11 interface endpoint ENIs in the second AZ, route table updates). Apply takes the same ~15-20 minutes (NFW endpoint creation dominates). Rollback is symmetric: revert the var, plan, apply — resources are torn down cleanly.
Note: the current az_redundancy variable supports single and multi; a follow-up module change adds dual as the explicit two-AZ option so it is not conflated with eventual three-AZ.
NFW data processing ($0.065/GB), NAT data processing ($0.045/GB), interface endpoint data ($0.01/GB), cross-AZ data ($0.01/GB). At POC traffic volumes (sub-1GB/day combined), variable cost is well under $20/month.
Permanent. Tearing down the network breaks every downstream module that has any workload attached. NAT and EIP have a few-second creation time but a sticky deletion (release EIP, then delete NAT, then delete subnet routing). Don't terraform destroy network casually.
substrate/environments/ai-sandbox/terraform.tfvars has safety toggles in safe positions:
nfw_enforcement_mode = "alert"dns_firewall_action = "ALERT"flow_logs_destination = "cloudwatch"az_redundancy = "single"cd substrate/environments/ai-sandbox
# Plan ONLY the network module (already done; re-plan if anything's changed)
terraform plan -target=module.network -out=network.tfplan
# READ the plan. Expected:
# Plan: 82 to add, 0 to change, 0 to destroy.
# All resource lines should be + create. NFW + VPC endpoints dominate the count.
terraform apply network.tfplan
# Capture outputs for downstream blocks (data, observability, lambda-tools)
terraform output > /tmp/network-outputs.txt
| Phase | Time | What's happening |
|---|---|---|
| VPC + IGW + subnets + route tables | ~30s | Foundational layer, parallel |
| Security groups + rules | ~10s | Parallel |
| EIP + NAT GW | ~3-4 min | NAT is the slowest single resource here |
| VPC endpoints (13) | ~3-5 min | Created in parallel; bound by AWS provisioning speed |
| Network Firewall + policy + rule groups | ~10-15 min | NFW is the longest-creating AWS resource in this entire build |
| DNS Firewall + logging | ~30s | Quick |
| Flow logs + IAM + log groups | ~15s | Quick |
| Total | ~15-20 min | Most of it waiting for NFW |
# 1. VPC exists with the right CIDR
AWS_PROFILE=ai-sandbox aws ec2 describe-vpcs \
--filters "Name=tag:Project,Values=ai-sandbox" \
--query 'Vpcs[].{VpcId:VpcId,Cidr:CidrBlock,State:State}' --output table
# 2. 9 subnets exist (3 AZs x 3 tiers)
AWS_PROFILE=ai-sandbox aws ec2 describe-subnets \
--filters "Name=tag:Project,Values=ai-sandbox" \
--query 'length(Subnets)' --output text
# Expected: 9
# 3. NFW is live and the firewall policy is attached
AWS_PROFILE=ai-sandbox aws network-firewall describe-firewall \
--firewall-name ai-sandbox-nfw \
--query '{Status:FirewallStatus.Status, Policy:Firewall.FirewallPolicyArn}'
# 4. NFW is in ALERT mode (stateful default action)
AWS_PROFILE=ai-sandbox aws network-firewall describe-firewall-policy \
--firewall-policy-name ai-sandbox-nfw-policy \
--query 'FirewallPolicy.StatefulDefaultActions' --output text
# Expected: aws:alert_strict (or similar alert-mode default)
# 5. DNS Firewall rule group association is in ALERT mode
AWS_PROFILE=ai-sandbox aws route53resolver list-firewall-rules \
--firewall-rule-group-id "$(aws route53resolver list-firewall-rule-groups \
--query 'FirewallRuleGroups[?Name==\`ai-sandbox-dns-fw\`].Id' --output text)" \
--query 'FirewallRules[].{Domain:FirewallDomainListId,Action:Action}' --output table
# Expected: catch-all rule has Action=ALERT (not BLOCK)
# 6. 13 VPC endpoints (2 gateway + 11 interface)
AWS_PROFILE=ai-sandbox aws ec2 describe-vpc-endpoints \
--filters "Name=tag:Project,Values=ai-sandbox" \
--query 'length(VpcEndpoints)' --output text
# Expected: 13
# 7. Flow logs are configured and writing to CloudWatch (audit CMK)
AWS_PROFILE=ai-sandbox aws ec2 describe-flow-logs \
--filter "Name=tag:Project,Values=ai-sandbox" \
--query 'FlowLogs[].{LogDest:LogDestination, FlowLogStatus:FlowLogStatus}' --output table
# Expected: FlowLogStatus=ACTIVE, LogDest contains "ai-sandbox" log group
This is the step where the visual story really helps. The VPC has nine subnets, three trust zones, a firewall, two NATs, and 14 endpoints — reading about it in text is one thing; seeing the topology in the AWS Console builds the mental model that makes the doctrine obvious. The "Resource map" view is especially worth a minute.
| What to see | Where to look | What confirms success (and what's visible doctrine) |
|---|---|---|
| VPC topology (the visual) | VPC → Your VPCs → click ai-sandbox-vpc → Resource map tab |
The whole VPC as a diagram. 9 subnets arranged by AZ. IGW attached. NAT Gateway in us-east-1a. Subnets color-coded by route table. The lockbox subnets visibly do not connect to the IGW — that's the structural isolation, in pixels. Click any subnet to see its route table. |
| The 9 subnets | VPC → Subnets → filter by VPC vpc-0ed3a16a8b82f7a34 |
3 agent-private (10.0.0.0/22, 10.0.4.0/22, 10.0.8.0/22), 3 lockbox-private (10.0.16.0/24, 10.0.17.0/24, 10.0.18.0/24), 3 egress (10.0.32.0/24, 10.0.33.0/24, 10.0.34.0/24). Click a lockbox subnet, scroll down to Route table, confirm no 0.0.0.0/0 entry. |
| Lockbox isolation, visible | VPC → Route tables → filter ai-sandbox-rt-lockbox |
Three route tables, one per AZ. Open any. The Routes tab shows: 10.0.0.0/16 → local, S3 prefix list → gateway endpoint, DDB prefix list → gateway endpoint. That's all. No internet route. See Step.3.routing-tables.html for the full walkthrough. |
| Agent default route to NFW | VPC → Route tables → filter ai-sandbox-rt-agent → click one |
Routes tab shows 0.0.0.0/0 → vpce-0119843d5454e1ed0 (the NFW endpoint). All outbound from agent workloads goes through the firewall first. Compare to the egress route table where 0.0.0.0/0 → igw-*. |
| Network Firewall live + alert mode | VPC → Network Firewall → Firewalls → click ai-sandbox-nfw |
Status: Ready. Sync state: In sync. Policy: ai-sandbox-firewall-policy. Click the policy → Stateful rule evaluation order: Strict. Default stateful actions: aws:alert_strict — alert mode confirmed visually. |
| NFW SoR allowlist (the 4 SoRs) | VPC → Network Firewall → Rule groups → click ai-sandbox-stateful-sor-allowlist |
Type: Stateful. Rule type: Domain list. Targets: lists the 4 SoRs (Salesforce subdomains, Monday, GitHub, GitBook). Target type: TLS_SNI, HTTP_HOST. Mode tag: alert. This is the allowlist, as data. |
| DNS Firewall in ALERT mode | Route 53 → Resolver → DNS Firewall rule groups → click ai-sandbox-dns-fw |
Two rules: priority 1 = allow the SoR domain list; priority 100 = catch-all wildcard with action ALERT (not BLOCK). Query logging: enabled. Associated to VPC. |
| VPC endpoints (14 total) | VPC → Endpoints → filter by VPC | 14 rows: 2 Gateway (S3, DynamoDB), 11 Interface (Bedrock×4, ECR×2, KMS, Secrets, STS, Logs, Lambda), 1 GatewayLoadBalancer (the NFW endpoint, AWS-auto-created). Click any interface endpoint → Subnets tab shows the AZ-bound ENI. |
| NAT Gateway + EIP | VPC → NAT gateways → ai-sandbox-nat-us-east-1a |
State: Available. Subnet: egress-us-east-1a. Elastic IP attached. This is the single egress source-IP for the whole VPC (single-AZ mode). |
| Internet Gateway | VPC → Internet gateways → ai-sandbox-igw |
State: Attached to vpc-0ed3a16a8b82f7a34. Note: only the egress route tables reference it. Agent and lockbox subnets do not. |
| VPC flow logs (active) | VPC → click ai-sandbox-vpc → Flow logs tab |
Status: Active. Destination: /aws/vpc/ai-sandbox/flow-logs CloudWatch log group. Encryption: audit CMK. Log format: custom 28-field. Capturing all (ACCEPT + REJECT). |
| NFW alert log (where blocked-or-not traffic shows up) | CloudWatch → Log groups → /aws/network-firewall/ai-sandbox/alert |
Log group exists, encrypted with audit CMK. Probably empty right now (no workloads to generate traffic). Once Step 4 deploys agents, alert entries appear here for any non-SoR destination — this is what's reviewed before flipping NFW to drop in Step 5. |
| VPC flow log entries (data in motion) | CloudWatch → Log groups → /aws/vpc/ai-sandbox/flow-logs → click any log stream |
Per-AZ streams. Each line is a flow record: source IP, dest IP, ports, packets, bytes, action (ACCEPT/REJECT), AWS service tags. Even without workloads, AWS-internal probes generate some entries — confirms the flow log pipeline is end-to-end functional. |
aws:alert_strict. This is the safe-mode posture.Steps 1-3 had to be read to be understood. Step 3 can be seen. The trust zones, the routing isolation, the chokepoint topology — they exist as pixels in the AWS Console exactly the way they exist in the doctrine. When future engineers (or auditors) come through, the Resource map is the artifact that explains the design without anyone having to read the Terraform.
-target warning (anticipated, ignored)Same as Step 2. Terraform will warn about -target being non-routine. Ignore.
The Network Firewall resource takes 10-15 minutes to create. During this window, terraform apply appears to hang on a single line:
aws_networkfirewall_firewall.main: Still creating... [11m20s elapsed]
Not a fix — just wait. AWS provisions NFW endpoint ENIs across the configured AZs and that takes real time. If the apply errors after 30+ minutes, then there's a real problem (check CloudTrail for NFW API errors).
20-minute apply, 12-hour SSO token. If you started the session 11h45m ago, the token will expire mid-apply with ExpiredToken.
Fix: re-run aws sso login --profile ai-sandbox before starting if you're near the boundary. If it dies mid-apply, re-auth and re-run terraform apply network.tfplan — Terraform is idempotent.
11 interface endpoints, all for AWS services. Almost all are available in us-east-1. Bedrock-specific endpoints (bedrock-runtime, bedrock-agent, bedrock-agent-runtime) require the service to be active in the region; us-east-1 has them all, but if AWS changes that, an endpoint may fail to create.
Fix: remove the unavailable endpoint from the network module's endpoint list, re-plan, re-apply. The endpoint can be added back when AWS launches it.
The module includes an aws_default_security_group resource that strips the default SG of all rules. If a previous apply (or manual console action) modified the default SG, Terraform may try to bring it under management and the diff might look surprising.
Verify: the plan output for aws_default_security_group.no_rules should show no ingress/egress rules. If it shows any, manual cleanup may be needed in console first.
The flow log delivery role is created in the same apply. AWS IAM is eventually consistent (~5-10s). If the flow log resource is created before IAM has propagated the role, it errors with InvalidParameter: Unable to assume role. Rare with current AWS performance.
Fix: wait 30 seconds, re-run apply. Terraform's depends_on reduces but does not eliminate this race.
terraform apply network.tfplan.aws sts get-caller-identity returns Account 959228203854 with SSO admin ARN. Token is fresh (re-auth if >10h old).
terraform plan -target=module.network shows 82 to add, 0 to change, 0 to destroy. All + create. Any drift on a freshly-deployed account is a no-go.
substrate/environments/ai-sandbox/terraform.tfvars has all four safety toggles in safe positions: nfw_enforcement_mode = "alert", dns_firewall_action = "ALERT", flow_logs_destination = "cloudwatch", az_redundancy = "single". Verify with grep -E "(nfw_enforcement|dns_firewall|flow_logs|az_redundancy)" substrate/environments/ai-sandbox/terraform.tfvars.
You have an uninterrupted 20-minute window. NFW creation alone is 10-15 minutes; total apply is 15-20 minutes. Not a good time to start if you have a meeting in 10 minutes.
If all four pass, run terraform apply network.tfplan.
The lockbox subnets have no default route. This is the strongest enforcement possible: the internet is not unreachable because of a policy decision, it is unreachable because there is no route for the packet to take. Removing the route is a structural property of the subnet, not a state of a firewall. Even if NFW is misconfigured, even if SGs are wide open, even if IAM grants every permission — a packet from a lockbox workload destined for the internet has nowhere to go.
NFW operates at L4 with stateful TLS SNI inspection. DNS Firewall operates at L7 with query-time hostname filtering. They are independent — a gap in one is covered by the other. Both are configured with the same 4-SoR allowlist, but they enforce it via different mechanisms. Defense in depth.
NFW does not know which IAM role originated a packet — it sees TLS handshakes and TCP/IP headers only. That is intentional: the chokepoint enforces "this destination is allowed for any workload" rather than "this destination is allowed for this role." Role-level scoping happens at the application layer (IAM policies on tier roles), and the network layer enforces the floor that no role can bypass.
Every traffic-affecting block in this build lands in observation mode first. Alert mode for NFW, ALERT for DNS Firewall, log-only for any future WAF. Enforcement is a separate, deliberate operator decision, gated on observed traffic patterns. The doctrine: never let an automation make the first enforcement decision.
After network apply succeeds and verification passes:
nfw_enforcement_mode or dns_firewall_action yet. They flip in Step 5, after Step 4 deploys real workloads.Network is now the substrate the rest of the platform attaches to. Every downstream module references at least one of network's outputs (vpc_id, agent_subnet_ids, lockbox_subnet_ids, agent_security_group_id, endpoint_security_group_id, etc.).