Back to Resume
PrivateSeptember 2023· 2 weeks

Advanced AWS Infrastructure with Terraform

Modular AWS Infrastructure as Code with Terraform

RoleInfrastructure Engineer

An e-commerce brand needed its AWS environment provisioned as code instead of clicked together by hand. I built a set of Terraform modules covering the network layer, a load-balanced web tier, a CDN for static assets, and three data-layer services, a relational database, a cache, and a message broker, composed from one root module. The static asset path works end to end. The dynamic app tier and the data-layer modules were captured mid build-out, and I document that state honestly rather than round it up.

The problem

An e-commerce brand's AWS footprint had grown past what could be safely clicked together in the console. Static assets, a web application, a database, a cache, and a message queue all needed provisioning, and doing that by hand does not survive a second environment or a departed engineer. They needed the stack defined as code: reproducible, reviewable, and split into pieces a single engineer could reason about one at a time, rather than one long imperative script or a wiki page of console steps.

Constraints

  • ·Built by a single engineer, so the module boundaries had to stay small enough to hold in one head.
  • ·AWS only, one region (eu-central-1), no multi-cloud requirement.
  • ·Terraform as the only IaC tool, starting from an empty module tree, nothing to build on top of.
  • ·Multi-service scope in one engagement: networking, a web tier, a CDN, and three separate data stores.

Architecture

One Terraform root module composes eight child modules. The VPC module is the network foundation: a VPC, three public and three private subnets across the given availability zones, one internet gateway, one NAT gateway, route tables, and two security groups. The ALB and EC2 modules build the web tier on top of the VPC's outputs. The S3 and CloudFront modules build the static-asset path: a private S3 bucket that only CloudFront can read, through an Origin Access Identity. The RDS, ElastiCache, and Amazon MQ modules provision the data layer, but as standalone declarations that reference security groups and a subnet group by hardcoded ID rather than by the VPC module's outputs.

  • VPC module: 1 VPC, 3 public + 3 private subnets, 1 internet gateway, 1 NAT gateway, route tables and associations, 2 security groups (alb_sg, app_sg)
  • ALB module: 1 application load balancer, 1 target group (IP target type), 1 HTTP listener on port 80, deletion protection on
  • EC2 module: 1 t2.medium web instance, placed in the public subnet list despite a private subnet variable also being passed in
  • S3 module: 1 private bucket for static assets
  • CloudFront module: 1 distribution with an Origin Access Identity in front of the S3 bucket, custom domain alias
  • RDS module: 1 MariaDB instance, standalone, referencing an external security group and subnet group by hardcoded ID
  • ElastiCache module: 1 two-node Redis cluster, same standalone pattern
  • Amazon MQ module: 1 single-instance RabbitMQ broker, same standalone pattern

Two paths exist in this stack. The static-asset path (client to CloudFront to the private S3 bucket) is fully wired. The dynamic app-tier path (client to ALB to the EC2 instance) and the three data-layer modules are declared but not connected to each other or to the VPC's outputs in this snapshot of the code.

How one request flows

  1. 01Terraform provisions the VPC: one VPC, three public and three private subnets across the given availability zones, an internet gateway, one NAT gateway, and the route tables tying each subnet to the right one.
  2. 02Two security groups are created: one for the load balancer, open on port 80 to the internet, one for the app instance, open on port 22 to the load balancer's security group only.
  3. 03The S3 module provisions a private bucket for static assets, never opened directly to the internet.
  4. 04The CloudFront module creates an Origin Access Identity and a distribution that reads from the S3 bucket only through that identity.
  5. 05A client requests a static asset from the CDN's domain alias. CloudFront serves it from cache when present, or fetches it from the private S3 origin through the Origin Access Identity and caches the response.
  6. 06In parallel, the ALB, its target group, and the EC2 web instance are provisioned for the application path, and the RDS, ElastiCache, and Amazon MQ modules provision the data layer, each declared independently of the VPC module's outputs.
  7. 07What is provisioned is the foundation for the full stack. The static-asset path works end to end as declared. The dynamic app-tier path needed a target group attachment and a security group rule opening port 80 before it would serve real traffic.

Engineering decisions

One Terraform module per AWS service

What. Split the stack into eight small modules (vpc, ec2, alb, cloudfront, s3, amazon mq, elasticache, rds) composed from one root main.tf, instead of one flat configuration.

Why. A single engineer can hold one module's blast radius in their head. Changing the CDN does not require reading the RDS module.

Tradeoff. More files and more variable wiring between modules than a flat main.tf, worth it once the module count passes a handful.

One NAT gateway for all three private subnets

What. A single NAT gateway in one public subnet handles outbound traffic for every private subnet, instead of one NAT gateway per availability zone.

Why. Cuts the NAT gateway bill to a third of the per-AZ pattern.

Tradeoff. The NAT gateway becomes a single point of failure for private-subnet egress. Chosen deliberately for cost on a single-environment stack, not appropriate once the environment needs to survive an AZ outage.

Private S3 origin behind an Origin Access Identity

What. The S3 bucket behind CloudFront is private. CloudFront reads it through an Origin Access Identity, never through a public bucket policy.

Why. The bucket is never exposed directly, only CloudFront's identity can read it.

Tradeoff. An earlier draft of this same distribution (kept in the file as a comment) used Origin Access Control instead. The OAI mechanism was kept for the live resource, the newer OAC path was not carried forward in this snapshot.

SSH scoped to the load balancer's security group, not a CIDR block

What. The app instance's security group allows port 22 only from the ALB's security group, instead of from a fixed IP range.

Why. Scoping ingress to a security group rather than a CIDR block means the rule tracks whatever is actually behind that group, not a static IP list that goes stale.

Tradeoff. This scoping was applied to the SSH rule only. The same security group has no rule for port 80, the port the ALB's listener actually forwards, so the app tier's HTTP path is not open yet even once the target group attachment exists.

Data-layer modules attached to existing account resources, not the VPC module

What. RDS, ElastiCache, and Amazon MQ take no input variables from the VPC module. Their security groups and, for RDS, its subnet group are hardcoded IDs pointing at resources this codebase does not create.

Why. Lets long-lived stateful services (the database, the cache, the broker) sit outside the app tier's lifecycle, so rebuilding the VPC or the web tier does not touch them.

Tradeoff. Couples these three modules to one specific AWS account's existing resource IDs. They cannot be applied standalone into a fresh account without those resources already existing, which is less portable than wiring through the VPC module's outputs.

What changed along the way

  • The CloudFront distribution went through at least one rebuild: an Origin Access Control based draft is still in the file as a full commented-out block, replaced by the live Origin Access Identity version.
  • The ALB, its target group, and the EC2 instance were declared ahead of finishing the wiring between them. This snapshot of the code is the network and CDN layer complete, with the app tier staged but not yet load-bearing.
  • Honest limitation: the CloudFront cache behavior's target_origin_id does not match the origin's own origin_id, which would fail on apply as committed. Left visible here rather than quietly fixed, because it is the kind of mismatch worth catching in review before it ships.

Security and reliability

  • ·The S3 bucket is private end to end, only readable through CloudFront's Origin Access Identity.
  • ·Network segregation: public and private subnets, a NAT gateway for private egress, and two purpose-built security groups.
  • ·SSH into the app instance is scoped to the load balancer's security group, not an open CIDR.
  • ·Deletion protection is enabled on the load balancer.
  • ·Two gaps disclosed rather than hidden: the RDS instance is provisioned with publicly_accessible on, and the app tier's HTTP path (ALB to EC2) is not fully wired in this snapshot, see the engineering decisions above.

Metrics

8
AWS services automated as separate Terraform modules
measured
3 + 3
public and private subnets provisioned across the AZ set
measured
3
standalone data-layer services declared (RDS, ElastiCache, Amazon MQ)
measured

What shipped

  • Delivered reusable Terraform modules for VPC networking, a load-balanced EC2 web tier, a CloudFront-fronted private S3 bucket, and three standalone data-layer services (RDS, ElastiCache, Amazon MQ).
  • The static-asset path works end to end: CloudFront serves cached content from a private S3 bucket that is never exposed directly.
  • The network foundation, three public and three private subnets, one NAT gateway, two purpose-built security groups, is ready to carry more than the single EC2 instance in this snapshot.
  • Honest handover note: the ALB-to-instance wiring and a CloudFront origin ID needed one more pass before this stack would apply and serve traffic end to end.

Lessons learned

  • ·A single shared NAT gateway is a reasonable cost tradeoff for one environment, but every private subnet inherits it as a hard dependency.
  • ·CloudFront's target_origin_id has to match the origin's own origin_id exactly. A one-word mismatch is invisible in a read-through but fails at apply.
  • ·Declaring a load balancer, a target group, and an instance is not the same as connecting them. A target group attachment (or an autoscaling group) is what makes the path real.
  • ·Wiring a data-layer module to hardcoded security-group and subnet-group IDs ties it to one AWS account's existing resources, worth naming clearly rather than presenting it as fully portable.
  • ·Security group rules and listener ports need to be reviewed together. An SSH-only ingress rule with no port 80 opened is easy to miss until real traffic is tested.

Tech stack

Backend
TerraformAWS EC2AWS Application Load BalancerAmazon VPC (subnets, route tables, NAT, IGW)AWS CloudFrontAWS S3
Data & state
Amazon RDS (MariaDB)Amazon ElastiCache (Redis)Amazon MQ (RabbitMQ)
Ops & quality
Terraform modules (one per AWS service)AWS security groupslocal Terraform state, no remote backend