Back to Resume
PrivateJuly 2024· 1 week

AWS ECS Infrastructure with CI/CD and KMS (Terraform)

An ECS Platform with an Encrypted Data Tier and a Self-Service Deploy Pipeline

RoleDevOps Engineer

A digital media company needed its containerized backend on AWS with an encrypted database, a private network, and a deployment path that didn't depend on someone SSHing in to push updates by hand. I built the infrastructure as one Terraform root module wiring 9 child modules: a 2-AZ VPC, an ALB-fronted ECS-on-EC2 service with CPU-based autoscaling, a Multi-AZ KMS-encrypted Postgres instance, and a CodeCommit-to-ECS CI/CD pipeline. Every tier below the ALB is reachable only from a named security group, never an open CIDR.

The problem

A digital media company needed a home for its containerized backend and its Postgres database, with the pipeline had to move a commit to a running container without a person doing it by hand each time. Running these pieces by hand, an EC2 instance here, a database clicked together there, a deploy pushed over SSH, works until someone forgets a step or a security group is left open longer than it should be. They needed the network, the compute, the data tier, and the deploy path defined as code, so any of it could be rebuilt the same way twice, with the data tier isolated and encrypted rather than reachable from the open internet.

Constraints

  • ·AWS-only, provisioned entirely through Terraform so the environment is reproducible.
  • ·The database and the application containers had to stay in private subnets, with no direct internet exposure.
  • ·A deployment path from source control to the running service, not a manual push.
  • ·One engineer, so the module boundaries had to stay simple enough to hold in one head.
  • ·Room to add ML and model-training capacity later without redesigning the network.

Architecture

One Terraform root module composes 9 child modules. A VPC spans 2 AZs with public subnets for the ALB and bastion and private subnets for ECS and RDS, each private subnet fed by its own NAT gateway. The ALB forwards HTTP traffic to an ECS service running on an EC2 capacity provider, which pulls its container image and writes logs to a dedicated CloudWatch group. The service reaches a Multi-AZ Postgres instance encrypted with a dedicated KMS key, reachable only from ECS and the bastion. A CodeCommit repository feeds a CodeBuild-CodeDeploy-CodePipeline chain that redeploys the ECS service on a push to main. Two further modules, one for ML instances and one for model-training instances, are written in full but commented out of the root module, so they exist as code without existing as infrastructure.

  • VPC: 1 VPC, 2 public + 2 private subnets across 2 AZs, 2 NAT gateways (one per public subnet), reference-scoped security groups
  • ALB: internet-facing, 1 HTTP target group, port-80 listener
  • ECS on EC2: 1 cluster, 1 task definition (1024 CPU / 4096 MiB), 1 capacity provider backed by an autoscaling group and launch template, CPU-target (80%) autoscaling on desired count
  • RDS: 1 Multi-AZ PostgreSQL 16.2 instance, KMS-encrypted, private-subnet only
  • KMS: 2 customer-managed keys with rotation enabled, one for RDS, one for S3
  • CI/CD: CodeCommit repo, CodeBuild project, CodeDeploy app/deployment group, CodePipeline (Source/Build/Deploy)
  • IAM: ECS task and task-execution roles, an S3 access policy, a CloudWatch Logs policy, a CodePipeline service role
  • S3: 1 versioned KMS-encrypted data bucket restricted to the VPC's NAT gateway IPs, 1 CodePipeline artifact bucket
  • CloudWatch: 1 log group for ECS (365-day retention)
  • Bastion host: 1 public-subnet EC2 instance, the only SSH path into the private subnets
  • ec2-ml and ec2-mt modules: launch config + autoscaling group each, written, not called from main.tf

The live path is VPC to ALB to ECS to RDS, with the CI/CD chain feeding new deployments into that same ECS service from the side.

How one request flows

  1. 01A commit lands on the CodeCommit repository's main branch.
  2. 02CodePipeline's Source stage pulls that commit into a source artifact.
  3. 03The Build stage runs CodeBuild against buildspec.yml, producing a build artifact stored in a dedicated pipeline S3 bucket.
  4. 04The Deploy stage hands the build artifact to CodeDeploy, targeting the ECS cluster and service built in the ecs module.
  5. 05ECS registers the new task definition and places tasks across both private-subnet AZs behind the capacity provider, guarded by a deployment circuit breaker.
  6. 06The ALB shifts traffic to the new tasks through its target group, and the running containers write logs to the CloudWatch group and reach Postgres over the RDS security group.

Engineering decisions

ECS on EC2 with a capacity provider, not Fargate

What. Ran the ECS service on an EC2 capacity provider tied to an autoscaling group and launch template, instead of Fargate.

Why. A capacity provider gives direct control over instance type, sizing, and the autoscaling policy behind the cluster, which Fargate abstracts away.

Tradeoff. Owning the EC2 layer means patching and sizing the instances directly, which Fargate would have handled instead.

Security groups scoped by reference, never by CIDR

What. Every security group below the ALB and bastion lists a source security group ID as its ingress rule, not a CIDR block.

Why. A reference-scoped rule only ever admits the specific tier it names, so a new security group added later cannot accidentally open a path to the data tier through a wide CIDR.

Tradeoff. Every new tier needs its security group wired in explicitly, there is no shortcut default-allow rule to fall back on.

A dedicated KMS key per encrypted service

What. Created two customer-managed KMS keys, one for RDS and one for S3, instead of using the AWS-managed default key for each.

Why. A dedicated key with rotation enabled keeps each service's encryption under this project's own key policy rather than a shared account default.

Tradeoff. Two keys to manage instead of relying on the account default, worth it for a database holding the client's data.

Bastion host as the one SSH path in

What. Put a single EC2 instance in the public subnet as the only thing with an open SSH ingress rule, and scoped every other tier's SSH access to come from that instance's security group.

Why. One narrow, known entry point is easier to audit and lock down than every private-subnet resource carrying its own SSH exposure.

Tradeoff. An operator has to hop through the bastion for shell access, which is the point, not a workaround.

Multi-AZ RDS over a single instance

What. Set the PostgreSQL instance to Multi-AZ rather than a single-AZ deployment.

Why. A digital media company's backend depends on the database being there, and Multi-AZ gives it a standby that AWS fails over to automatically.

Tradeoff. A second, idle standby instance running at all times, a cost accepted for the availability.

Deployment circuit breaker without auto-rollback

What. Turned on the ECS deployment circuit breaker so a failing deployment halts, but left automatic rollback off.

Why. Traced directly from ecs/main.tf: `deployment_circuit_breaker { enable = true, rollback = false }`. The circuit breaker stops a bad rollout from replacing every healthy task.

Tradeoff. Recovering from a halted deployment is a manual step, not an automatic revert, which was the setting as built.

Two more modules built ahead of being wired in

What. Wrote the ec2-ml and ec2-mt modules in full, launch configuration, autoscaling group, and scaling policies each, but left the calls to them commented out in main.tf.

Why. Staging a module in the codebase before connecting it keeps the design visible and ready without touching a working deployment to add it.

Tradeoff. The client does not get ML or model-training capacity from this stack today, only the groundwork for it.

What changed along the way

  • The ec2-ml and ec2-mt modules were written for future ML and model-training capacity, then left commented out of main.tf, so the delivered stack stayed ECS-only.
  • The database username, password, and the client's project name were originally hardcoded as Terraform variable defaults, and they were pulled out into a placeholder-plus-.env pattern when this project was prepared for the portfolio.
  • The CodeDeploy deployment group is configured with an EC2-style deployment config rather than one of the ECS-specific ones, a gap this trace surfaced rather than something exercised against a live deployment.

Security and reliability

  • ·Every security group below the ALB and the bastion is scoped to a source security group, not an open CIDR.
  • ·RDS is Multi-AZ, not publicly accessible, and encrypted with a dedicated customer-managed KMS key.
  • ·The S3 data bucket is versioned, private-ACL, KMS-encrypted, and denies requests from any source IP outside the VPC's own NAT gateway IPs.
  • ·ECS deployments are guarded by a circuit breaker that halts a bad rollout, though rollback is not automatic.
  • ·ECS logs retain for 365 days in a dedicated CloudWatch log group.
  • ·The CodePipeline service role is the one IAM role in this stack scoped broadly (wildcard S3/CodeCommit/CodeBuild/CodeDeploy access) rather than to a specific resource.

Metrics

9
Terraform modules wired into one root deployment
measured
2
dedicated KMS keys, one per encrypted service
measured
0
security groups open to an unrestricted CIDR below the ALB and bastion
measured

What shipped

  • A working Terraform root module that stands up a full ECS-on-EC2 environment for the client: VPC, ALB, ECS cluster/service, RDS, S3, KMS, IAM, bastion, and a CodePipeline deployment path.
  • Security groups are reference-scoped end to end - no tier accepts traffic from an open CIDR except the ALB's public listeners.
  • RDS and the S3 data bucket are both KMS-encrypted with dedicated keys and key rotation enabled.
  • The ML/model-training EC2 modules were built and left in the repo, commented out, as a designed-but-not-connected extension point.

Lessons learned

  • ·Wiring 9 modules through explicit variable and output passing keeps a Terraform root readable without a shared state layer, as long as every new module's inputs are traced back to their source.
  • ·Security groups that reference other security groups by ID, instead of a CIDR, scale cleaner as more tiers get added to a VPC.
  • ·An ECS CodeDeploy deployment group needs an ECS-specific deployment config and blue/green wiring, an EC2-style config quietly leaves that stage unusable.
  • ·Commenting a whole module and its call site out together, rather than deleting it, keeps designed-but-unbuilt work visible for whoever picks the project back up.

Tech stack

Backend
Amazon ECS on EC2 (capacity provider + launch template)AWS Application Load Balancer
Data & state
Amazon RDS (PostgreSQL 16.2, Multi-AZ)Amazon S3 (versioned, KMS-encrypted)AWS KMS (customer-managed keys)
Ops & quality
Terraform (AWS provider ~> 5.0)AWS CodePipeline / CodeBuild / CodeDeployAWS IAMAmazon CloudWatch LogsBastion host for SSH access