Back to Resume
PrivateApril 2024· 5 weeks

AWS Services Architecture Implementation Using Terraform

AWS Infrastructure as Code for a Containerized Web Service

RoleInfrastructure Engineer (Terraform)

A software company needed its containerized web application running on AWS without hand-clicked infrastructure. I wrote a modular Terraform stack: a two-AZ VPC, an ECS Fargate service behind a load balancer, a private S3 bucket fronted by CloudFront for static assets, and the IAM role the tasks need. Five independent modules compose from one nine-line root file, so any layer can be read or changed on its own.

The problem

The client had a containerized application and needed it deployed to AWS in a way that could be rebuilt from code, not assembled by hand in the console. They needed a private network with public and private subnets, a load-balanced compute layer for the app, and a place to serve static assets without exposing storage directly to the internet. A one-off console setup would work once, but it could not be reproduced, reviewed, or handed to another engineer with confidence.

Constraints

  • ·AWS only, single region (eu-central-1).
  • ·Application already containerized, so the compute layer had to run containers, not raw EC2 app servers.
  • ·Static assets needed a public-facing path without making any storage bucket directly reachable.
  • ·Built and delivered by one engineer, so the module count had to stay manageable to review and hand over.
  • ·No existing CI pipeline, changes apply from a workstation with the right credentials.

Architecture

One root module wires five child modules. A VPC module builds the network: two public and two private subnets across two AZs, one NAT gateway, and the security groups for the load balancer, the ECS tasks, a bastion host, and an RDS layer. An ECS module runs a Fargate service (3 tasks, 4 vCPU / 8 GB each) behind an Application Load Balancer, plus a small EC2 bastion for operator access into the private subnets. An S3 module holds a fully private bucket, read only through a CloudFront module using a signed Origin Access Control. An IAM module defines the one role the ECS tasks assume to pull their image and write logs. A sixth module defines an ECR repository but is not called from the root file, so it exists in the repo without being part of the deployed stack.

  • VPC: 2 public / 2 private subnets across 2 AZs, 1 NAT gateway, security groups for ALB, ECS tasks, bastion, and RDS
  • ECS Fargate service: 3 tasks (4 vCPU / 8 GB), behind an Application Load Balancer, awsvpc networking
  • EC2 bastion host in the public subnet, SSH-only operator access into the private subnets
  • S3 bucket: fully private, public access blocked on all settings
  • CloudFront distribution: reads the S3 origin through a signed Origin Access Control
  • IAM: one ECS task execution role, scoped to the AWS-managed pull-and-log policy
  • ECR module: defined in the repo, not wired into the root module

Two independent paths share the same VPC: application requests go ALB to Fargate, static assets go CloudFront to S3. Neither path touches the other.

How one request flows

  1. 01A request reaches the Application Load Balancer in a public subnet on port 80.
  2. 02The ALB forwards it to a target group on port 5500, pointed at the ECS Fargate tasks running in the private subnets.
  3. 03The ECS service keeps 3 Fargate tasks alive (4 vCPU / 8 GB each). Each task gets its own network interface in the private subnet through awsvpc networking.
  4. 04Each task starts from the image address written into its task definition, an ECR image URI, not the ECR module defined elsewhere in the repo.
  5. 05The task runs under the ECS task execution role, which grants only the AWS-managed permission to pull the image and write logs, nothing broader.
  6. 06Any outbound call the task makes, the image pull, log delivery, leaves the private subnet through the single NAT gateway, the only route out.
  7. 07Logs stream out through the awslogs driver to CloudWatch Logs.

Engineering decisions

Fargate over self-managed EC2 for the app tier

What. The ECS service runs on the Fargate launch type instead of an EC2 capacity provider, with awsvpc networking giving every task its own elastic network interface.

Why. No node fleet to size or patch, and each task gets its own security group boundary instead of sharing one on a shared instance.

Tradeoff. Reserved or spot EC2 capacity costs less at steady, predictable load. Fargate was chosen because the workload's demand pattern was not fixed yet, and not managing instance scaling was worth the higher per-task cost.

S3 stays private, CloudFront holds the public edge

What. The bucket blocks every public access path, ACL set to private and all four block-public-access settings on, and CloudFront reaches it through an Origin Access Control with SigV4 signing.

Why. Assets are still public to end users, but only through the CDN. The bucket itself is never a valid URL to guess.

Tradeoff. A public bucket is simpler to wire and debug directly. The OAC adds one more piece of config in exchange for the origin never being reachable outside CloudFront.

One NAT gateway for two private subnets

What. A single NAT gateway sits in one public subnet and is wired into both private route tables.

Why. One NAT gateway costs less per month than one per availability zone.

Tradeoff. If that AZ has an outage, both private subnets lose outbound internet access at once. A NAT gateway per AZ removes that shared failure point at roughly double the cost. For this stack's traffic, one gateway was the right call, and it is a straightforward addition later if availability needs push the other way.

A bastion host instead of Session Manager for private access

What. A t2.micro EC2 instance in the public subnet, reachable over SSH, is the operator's only path into the private subnets.

Why. Fast to stand up, and every engineer already had SSH tooling ready to use.

Tradeoff. AWS Systems Manager Session Manager avoids opening port 22 to the internet at all. The bastion was the faster path for a small stack. Session Manager is the stronger default and the clear next step if this environment grows.

Five independent modules, one root wiring file

What. VPC, ECS, S3, CloudFront, and IAM are each their own Terraform module with explicit inputs and outputs, composed by a nine-line root main.tf.

Why. Any one layer, the network, the compute, the storage, can be read, changed, or reused without touching the others.

What changed along the way

  • An ECR module for the app's container registry lives in the repo, but the root main.tf never calls it. The task pulls from a hardcoded image address instead. Wiring the module in and pointing the task definition at its output is the clear next step.
  • An RDS subnet group and a MySQL security group are provisioned for a data layer, but no aws_db_instance is declared anywhere. The network path was built ahead of the database itself.

Security and reliability

  • ·The ECS task execution role is scoped to one AWS-managed policy, pull the image and write logs, no inline or broader grants.
  • ·The S3 bucket blocks public access on all four settings and is reachable only through CloudFront's signed Origin Access Control.
  • ·The app tier and the load balancer sit in private and public subnets respectively, with one NAT gateway as the only outbound path from private.
  • ·Operator access to the private network runs through a single bastion host rather than exposing the app's own ports.

Metrics

5
independent Terraform modules composing the stack
measured
3
Fargate tasks kept running by the ECS service
measured
2
availability zones the network spans
measured

What shipped

  • Shipped a modular Terraform stack: a two-AZ VPC, an ECS Fargate service with its load balancer, a private S3 and CloudFront static path, and the IAM role the tasks need.
  • The ECS service runs 3 Fargate tasks (4 vCPU / 8 GB each) behind a load balancer, each with its own network interface via awsvpc networking.
  • The S3 bucket is unreachable directly. Every asset request goes through CloudFront's signed origin access.
  • Delivered as five composable modules, so a later engineer can extend or replace one layer, such as adding a NAT gateway per AZ, without touching the rest.

Lessons learned

  • ·A module existing in a repo does not mean it is live. Always trace back to the root main.tf to see what is actually wired in.
  • ·A single NAT gateway is a fair tradeoff for cost until the private subnets it feeds start representing two independent failure domains.
  • ·A security group rule with an empty source list still applies cleanly. Terraform will not warn that a rule authorizes nothing, that has to be caught by reading the rule, not just running plan.
  • ·Pairing a fully private S3 bucket with an Origin Access Control costs one extra piece of config for a real guarantee: the origin is never reachable outside the CDN.

Tech stack

Backend
ECS FargateApplication Load BalancerEC2 bastion host
Data & state
S3 (private bucket)CloudFront CDNRDS network path staged (subnet group and security group only, no database provisioned)
Ops & quality
Terraform (AWS provider ~> 4.9)AWS VPC, multi-AZ subnets, NAT gatewayIAM task execution roleECR (module defined, not wired into the root)