▸ prd.vault

vault

22 — Environment Resources

docs/22-Environment-Resources.mdtype: infrastructureupdated: 2026-08-11

Environment Resources

What each environment is made of, resource by resource. 17-System-Design shows how the pieces talk to each other; this page is the inventory — counts, sizes and which flag turns each one on. Everything here comes from one shared Terraform module, infra/terraform/modules/platform, driven by three sets of variable values.

The three roots differ in quantity and topology, never in shape. That is deliberate: a control proven in one environment behaves the same in the next, and the only variables are how many of a thing exist and whether it is reachable from the internet.


Dev

Public-subnet single instance. Web and worker share a host, egress goes straight out through the internet gateway, and there is no NAT to pay for.

flowchart TB subgraph net["VPC · 2 public subnets · internet gateway"] direction TB alb["ALB<br/>SG-ALB · ACM certificate<br/>HTTP :80 → 301 · HTTPS :443"] ec2["EC2 t4g.small · ARM64<br/>Elastic IP · IMDSv2 required<br/>SG-APP: ingress from SG-ALB only<br/>web + worker containers"] rds[("RDS PostgreSQL 16 · db.t4g.small<br/>single-AZ · KMS encrypted<br/>SG-DB: 5432 from SG-APP, no egress")] end subgraph reg["Regional services"] waf["WAF web ACL<br/>rate limit per IP"] ecr["ECR repository<br/>one image per commit"] sm["Secrets Manager<br/>app env + Fireblocks key"] ssm["SSM parameter<br/>current image tag"] kms["KMS CMK<br/>rotation enabled"] logs["CloudWatch log group"] r53["Route 53 zone<br/>+ ACM certificate"] end waf --- alb alb --> ec2 ec2 --> rds ec2 --- sm ec2 --- ssm ec2 --- ecr ec2 --- logs sm --- kms rds --- kms r53 --- alb
Figure 1 — Dev resources — one of everything, public subnet, no NAT

Beta

Production topology at one of everything, behind an allowlist. Private subnet, NAT, the free S3 gateway endpoint and the full edge — the difference from Prod is redundancy and reach, not architecture.

flowchart TB subgraph pub["Public subnets ×2 — ALB and NAT only, no instances"] albb["ALB · ACM certificate · access logs"] natb["NAT gateway ×1"] end subgraph appb["Private app subnet ×1 — one AZ"] asgb["app ASG · min = desired = 1, max = 2 · t4g.small<br/>SG-APP · container memory limit"] wrkb["worker ASG · pinned to 1 · t4g.small<br/>pg-boss owner"] end subgraph datab["Private data subnets ×2 — subnet group spans 2 AZ"] rdsb[("RDS db.t4g.small single-AZ<br/>KMS encrypted · 30-day PITR · no standby")] s3gwb["S3 gateway endpoint<br/>free · image layers"] end subgraph regb["Regional"] wafb["WAF + managed rule groups<br/>+ admin allowlist + site allowlist"] alarmb["CloudWatch alarms incl. CPU credits + SNS topic"] end wafb --- albb albb --> asgb asgb --> rdsb wrkb --> rdsb asgb -.- s3gwb wrkb -.- s3gwb wrkb --> natb alarmb -.- rdsb alarmb -.- albb
Figure 2 — Beta resources — production topology, single AZ, no standby and no replica

Two subnets is a floor, not a choice

AWS refuses an ALB with fewer than two subnets in two Availability Zones, and refuses a DB subnet group that does not span two. Beta therefore keeps two public and two database subnets while running its workload in one AZ.

Prod

Everything above, doubled where redundancy matters, plus the pieces that only exist here: a standby database and a cross-region replica. The Co-Signer is not in this stack; see Custody and access.

flowchart TB subgraph pubp["Public subnets ×2 — ALB and NAT only"] albp["ALB · ACM certificate<br/>access logs → S3, 90-day expiry"] natp["NAT gateway per AZ ×2"] end subgraph appp["Private app subnets ×2 — one per AZ"] asgp["app ASG · 2–4 instances · t4g.small<br/>rolling refresh, capacity held flat"] wrkp["worker ASG · pinned to 1 · t4g.small"] end subgraph datap["Private data subnets ×2"] rdsp[("RDS db.t4g.small · Multi-AZ<br/>50 GB · 30-day backups + PITR")] s3gwp["S3 gateway endpoint<br/>free · image layers"] end subgraph drp["DR region — separate VPC, no internet route"] repp[("Cross-region read replica<br/>own KMS CMK<br/>security group with no rules")] laga["Replica-lag alarm"] end subgraph regp["Regional"] wafp["WAF + managed rules + admin allowlist"] alarmp["7 CloudWatch alarms → SNS"] budget["Monthly cost budget"] end wafp --- albp albp --> asgp asgp --> rdsp wrkp --> rdsp asgp -.- s3gwp wrkp -.- s3gwp wrkp --> natp rdsp -.->|"encrypted async replication"| repp repp --- laga alarmp -.- rdsp alarmp -.- albp alarmp -.- asgp
Figure 3 — Prod resources — two AZ, Multi-AZ database, cross-region replica and enclave signing

Inventory

Resource Dev Beta Prod
VPC 1 1 1 + 1 in the DR region
Public subnets 2 2 2
Private app subnets — 1 2
Private data subnets 2 2 2 (+2 in DR)
Internet gateway 1 1 1
NAT gateways — 1 2, one per AZ
Interface VPC endpoints — none by default none by default (interface_endpoint_services)
S3 gateway endpoint — 1 1
Load balancer ALB ALB ALB + access logs to S3
App compute 1 × t4g.small + Elastic IP ASG of 1, t4g.small ASG 2–4 across 2 AZ, t4g.small
Worker compute same host ASG of 1 ASG of 1
Database db.t4g.small, single-AZ db.t4g.small, single-AZ, 30-day PITR db.t4g.small, Multi-AZ, 50 GB
Read replica — — cross-region, own CMK
Security groups SG-ALB, SG-APP, SG-DB same (+ SG-VPCE only with interface endpoints) + SG-REPLICA (+ SG-VPCE only with interface endpoints)
WAF web ACL, rate limit + managed rules, admin allowlist, site allowlist + managed rules, admin allowlist
KMS keys 1 1 1 + 1 in the DR region
Secrets app env, Fireblocks key same same
CloudWatch alarms — set, incl. CPU credits 9, incl. CPU credits and replica lag
SNS topic — 1 1
Cost budget optional optional monthly
Co-Signer — shared host in the custody stack shared host in the custody stack

What turns each one on

Nothing above is conditional on an environment name. Every difference is a variable, which is why the same module produces all three:

Variable Turns on
compute_mode single for one EC2 instance, asg for Auto Scaling Groups
app_subnet_cidrs Private app subnets. One entry pins the workload to a single AZ
enable_nat_gateway NAT per app subnet
interface_endpoint_services Interface endpoints for the listed services; the free S3 gateway exists whenever private subnets do
db_multi_az The standby database instance
db_replica_region The cross-region replica, its DR VPC and its own CMK
enable_waf_managed_rules, admin_allowed_cidrs Managed rule groups and the admin allowlist
enable_cloudwatch_alarms, alarm_email_addresses Alarms and their SNS delivery
enable_alb_access_logs The log bucket and its retention rule
site_allowed_cidrs The site-wide WAF allowlist with exact health, webhook and mail-image exemptions
container_memory_limit The Docker memory cap on fleet containers

Several of these are fail-closed against each other: private subnets with neither NAT nor endpoints, alarms with no subscriber, a budget with no recipient, or a replica in the same region as its source all stop the plan rather than producing something that looks configured and does nothing.

Custody and access

Two small stacks in the production account support both environments and belong to neither:

Stack Resources Why it is separate
infra/terraform/custody Own VPC, one private subnet, one NAT; a c5.xlarge Nitro Enclave host running the Fireblocks Co-Signer; an S3 bucket and KMS key for its state; a security group with no ingress and egress on 443 only One Co-Signer serves the beta and live API users. It only makes outbound calls, so it needs no path into either application VPC
infra/terraform/access Own tiny VPC; a t4g.nano Tailscale routing peer with an Elastic IP; a Secrets Manager entry for its setup key The one stable address behind Beta's site allowlist and both admin allowlists. Its route covers the AWS eu-north-1 prefixes only, so tester traffic to anything else goes direct