Back

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.

TerraformAWS VPCEC2RDS MySQLIAMSSM Session ManagerHCL
View source
0SSH ports open
3Tier-isolated SGs
2Availability zones

Phase 02 / Architecture

Three tiers, two AZs.

INTERNETInternet Gatewayattached to VPCVPC · 10.0.0.0/16us-east-1aus-east-1bPUBLIC · 10.0.1.0/24EC2 · nginxt3.micro · SSM onlyPUBLIC · 10.0.2.0/24NAT Gateway+ Elastic IPAPP · 10.0.11.0/24 · PRIVATE[ provisioned · no compute ]APP · 10.0.12.0/24 · PRIVATE[ provisioned · no compute ]DB · 10.0.21.0/24 · ISOLATEDRDS MySQL 8db.t3.micro · encryptedDB · 10.0.22.0/24 · ISOLATED[ multi-AZ ready ]44380803306outbound · cross-AZ→ IGW → outSG CHAIN: WEB → APP → DB · IDENTITY-BASED REFERENCES

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

Web SGINTERNET-FACING

HTTPS from 0.0.0.0/0

The only tier with a public-internet rule. Holds the nginx instance reached via SSM, not SSH.

App SGIDENTITY-CHAINED

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.

DB SGFULLY ISOLATED

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