Terraform Infrastructure for a Containerized Backend
Terraform Infrastructure for a Containerized Backend
A backend team needed their AWS environment defined as code instead of clicked together by hand. I wrote the Terraform for the full stack, a VPC, an ECS cluster, an Aurora PostgreSQL database, a Redis cache, and a search domain, plus the CodeBuild spec that builds and pushes the app's Docker image. The result is a stack the team can read, review, and rebuild from source.
The problem
The client runs a backend service that needed a real AWS home: a database, a cache, a place to search or index data, and somewhere to run the container. Provisioning that kind of stack by hand in the console is slow, hard to review, and easy to get wrong twice in a row. They needed the pieces defined once, in code a reviewer could read line by line, with a single environment as the target and a build pipeline that turns a commit into a deployable Docker image.
Constraints
- ·Single AWS account, single Terraform state, no per-environment split
- ·App ships as a Docker image, so the build pipeline and compute layer had to be built around that
- ·Postgres, Redis, and search all as AWS managed services rather than self-hosted
- ·Built and reviewed by one engineer, so every module had to stay small enough to read in one pass
Architecture
One Terraform root module wires six child modules: a VPC that owns all networking and security groups, and five siblings (ECS, RDS, ElastiCache, OpenSearch, ALB) that consume the VPC's outputs but never reference each other directly. An S3 module for logs stands alone. A CodeBuild spec builds and pushes the Docker image outside the Terraform graph.
- ›VPC: 2 AZs, 2 public + 2 private subnets, 2 NAT gateways (one per AZ), every security group
- ›ECS: Fargate cluster and task execution role only, task definition and service are commented out
- ›RDS: Aurora PostgreSQL cluster, 1 instance, password generated and stored in Secrets Manager
- ›ElastiCache: single-node Redis, no replication
- ›OpenSearch: one domain, deployed outside the VPC with an open access policy, generated master credentials
- ›S3: one bucket for application logs
- ›ALB: built as a module, not wired into the root stack
- ›CodeBuild: buildspec.yml builds the Docker image, tags it with the commit hash, pushes to ECR, writes imagedefinitions.json
How one request flows
- 01A commit lands on the tracked branch and triggers the CodeBuild project
- 02CodeBuild logs into ECR and resolves the short commit hash
- 03Docker builds the image and tags it both latest and the commit hash
- 04Both tags push to ECR
- 05CodeBuild writes imagedefinitions.json for a CodePipeline ECS deploy action to read
- 06That file is the last artifact this stack produces, the deploy stage and ECS service that would consume it are configured outside this Terraform
Engineering decisions
Generated secrets instead of typed-in passwords
What. RDS and OpenSearch master passwords are created with Terraform's random_password and written into Secrets Manager, never into a .tf file or variable default.
Why. Removes an entire class of leaked-credential mistakes at close to zero setup cost.
Tradeoff. Rotating a password means a new apply, not a console click. That cost is worth never having a plaintext credential in source.
Two NAT gateways instead of one
What. Each private subnet gets its own NAT gateway in its own AZ.
Why. Outbound access survives a single AZ's NAT failing.
Tradeoff. Costs more and adds a small amount of extra state than sharing one NAT across both private subnets, which was the simpler alternative.
One Terraform module per AWS service
What. VPC, ECS, RDS, ElastiCache, OpenSearch, S3, and ALB each got their own module with explicit inputs and outputs, wired together only through the root module.
Why. A reviewer can read any one module without holding the whole stack in their head.
Tradeoff. Slower to write once than a flat set of .tf files, but far easier to review or change one piece later.
ALB and the ECS service built, then deliberately left unwired
What. The ALB module and the ECS task definition and service are complete, reviewable code, commented out at the point where they'd join the root stack.
Why. Keeps unfinished work visible and reviewable instead of deleting it or applying it half-finished.
Tradeoff. The tradeoff is an ECS cluster that does not run anything yet, chosen over forcing a service live before its design was settled.
OpenSearch placed outside the VPC, with the cost named
What. The domain's vpc_options are commented out, so OpenSearch sits outside the VPC with an open access policy and fine-grained access control as the remaining gate.
Why. VPC placement did not work out in the time available, and this got a working domain shipped.
Tradeoff. Buys a working domain sooner, at the cost of anyone with the endpoint being able to attempt a login. A VPC-scoped domain is the honest next step, not a solved problem.
What changed along the way
- →The ECS service and the ALB were both drafted early, then pulled out of the live path once the compute layer needed more design time than the rest of the stack. They stayed in the repo as commented-out modules instead of being deleted.
- →The RDS module was adapted from an earlier project's module and still carries that project's internal resource names, cosmetic, but worth renaming fully next time a module is reused.
- →OpenSearch moved from a planned VPC-scoped domain to a public one with an open access policy once VPC placement did not work out in the time available. Documented as a known limitation, not the final design.
Security and reliability
- ·Database and search credentials generated by Terraform and stored in AWS Secrets Manager, never hardcoded
- ·RDS encrypted at rest via a dedicated KMS key, OpenSearch encrypted at rest and in transit with HTTPS enforced
- ·Known gap stated plainly: the RDS instance is publicly accessible and OpenSearch sits outside the VPC with an open access policy, both rely on generated credentials rather than network isolation as the remaining control
- ·No automatic CI/CD-triggered deploy exists yet in this repo, the build pipeline stops at producing a tagged image and a deploy manifest
Metrics
What shipped
- ✓Shipped a reviewable, modular Terraform stack: VPC, ECS cluster, Aurora PostgreSQL, ElastiCache Redis, OpenSearch, S3, and an ALB module
- ✓Shipped a working CodeBuild pipeline that builds, tags, and pushes the app's Docker image on every commit
- ✓Left the ECS service and ALB wiring as reviewed, documented, commented-out code rather than shipping either half-finished
- ✓Documented the real gaps, the public RDS instance and the VPC-less OpenSearch domain, as the next work, not hidden debt
Lessons learned
- ·Generating secrets with Terraform and Secrets Manager from day one removes an entire class of leaked-credential mistakes for almost no setup cost
- ·Leaving unfinished work as commented-out, reviewable code beats deleting it or forcing it live, the next engineer can see exactly what was tried and why it paused
- ·A resource name copied from another project's module is a small thing until someone greps the codebase for it and gets a partial answer, rename fully when reusing a module
- ·Public network exposure can be the fastest path to a working demo, but it has to be logged as a known gap the moment it is chosen, not discovered later