Secure 3-Tier VPC
A production-pattern AWS VPC — public web, private app, isolated DB — deployed end-to-end with Terraform. Zero SSH, no bastion, no key pairs.
Phase 02 / Architecture
Three tiers, two AZs.
01 / The problem
I kept seeing Terraform on every cloud job posting and had no real idea what it was or why it mattered. I also didn't have a clear mental model of how traffic actually flows through a VPC — public vs private subnets, route tables, NAT, security groups. Reading about it wasn't sticking. I needed to build something end-to-end to make the pieces click.
02 / The solution
I started this in the AWS console. Creating three subnets by hand was already tedious — and I needed six. That friction is what pushed me into Terraform. Suddenly the same setup was a few count blocks instead of an hour of clicking.
The build: one VPC, six subnets across two AZs in three tiers, three security groups chained by identity (not CIDR), applied incrementally in four terraform apply waves. Web tier reached only through SSM Session Manager — no SSH, no bastion. Database tier has no route to the internet at all and is reachable only from the app tier.
This was my first real Terraform project and my first time deploying infrastructure as code.
03 / The tier model
HTTPS from 0.0.0.0/0
The only tier with a public-internet rule. Holds the nginx instance reached via SSM, not SSH.
Port 8080 from Web SG
Source is the Web SG itself, referenced by ID — not the CIDR of the web subnet. Survives subnet changes and IP reassignments.
Port 3306 from App SG
Zero CIDR rules. No route to the internet at all. Even a misconfigured database listener has no path to the public internet.
The point of the chain isn't which ports are open — it's how each rule references its source. CIDR-based rules calcify the network topology into the security policy: change a subnet, lose connectivity. Identity-based rules don't care which IP a packet comes from, only which security group the source is attached to. The DB SG has zero CIDR rules and zero internet path. The protection is structural, not credential-based.
04 / Trade-offs
Single NAT gateway
One NAT in AZ-b serves both AZs. If AZ-b goes down, the app tier loses outbound internet. Production deploys a NAT per AZ; this is a deliberate cost trade for a learning build.
App tier provisioned, no compute
Subnets, route tables, and the App SG exist. No server runs there yet — the network pattern is correct and adding an app instance is a one-resource change.
Free-tier instance sizing
t3.micro EC2 + db.t3.micro RDS. Realistic instance classes change cost, not architecture.
Single-AZ RDS
DB Subnet Group spans both AZs (multi-AZ ready) but the database itself runs single-AZ. Flipping multi_az = true would promote it.
05 / What I learned
Multi-AZ from day one.
Putting subnets in only one AZ saves nothing — RDS won't even let you create a subnet group without two. Designing for AZ failure isn't an 'advanced' concern, it's the baseline. One outage in us-east-1a takes down a single-AZ stack completely.
Route tables vs security groups — different layers, different jobs.
Route tables answer 'where can this packet go?' Security groups answer 'is this packet allowed?' A packet needs both — a path AND permission. Misconfigured route table means traffic never leaves. Misconfigured SG means traffic arrives and gets dropped. They look similar in tutorials but operate at completely different layers.
NAT Gateway: the one-way door for private subnets.
The path that finally clicked: private instance sends a packet to 0.0.0.0/0; private route table forwards it to the NAT Gateway; NAT (in the public subnet) swaps the private IP for its Elastic IP; public route table forwards to the Internet Gateway; IGW pushes to the internet. Return traffic follows the reverse, but unsolicited inbound traffic can't initiate — that's what makes it a one-way door. NAT must live in a public subnet because it needs IGW access to function.
sensitive = true is a display flag, not encryption.
It hides values from CLI output during plan and apply. The value sits in terraform.tfstate in plaintext. Anyone with state file access reads it directly. Real secret protection means encrypted remote state (S3 + KMS) and tools like Secrets Manager — not a true/false flag.
No SSH, no bastion, no key pairs.
SSM Session Manager replaces the entire SSH stack. The instance gets an IAM role with AmazonSSMManagedInstanceCore, and access happens through AWS APIs over the SSM agent's outbound connection. No port 22 open anywhere, no key pair management, no bastion host to patch, full audit trail in CloudTrail. The old 'SSH to bastion, SSH to private instance' pattern is legacy at this point.
© 2026 Jeff Lubin
← Home