In short:
- A fixed-price, five-day client project: a container app on AWS, deployed by a pipeline, with all the infrastructure as code.
- Terraform builds the network, load balancer, registry, service and alarms. GitHub Actions deploys every merge to main with OIDC, so no AWS keys are stored.
- Skipping the NAT gateway saves about $32 a month, and one command builds or removes the whole stack.
This was a fixed-price, five-day client project: a container app on AWS, deployed by a pipeline, with the infrastructure written as code. I am keeping the client private, and the repository is private too, so there is no link to it.
The diagram above is the handover map. This post explains what is in it and why.
What was built
A small Node.js API runs as a container on AWS ECS Fargate behind an application load balancer. Everything in AWS is created by Terraform, and every merge to main deploys through GitHub Actions. The pieces are:
| Piece | What it does |
|---|---|
| Network | A VPC (10.20.0.0/16) with two public subnets in two availability zones and an internet gateway |
| Load balancer | An application load balancer with a health check on /health
|
| Registry | An Amazon ECR repository with immutable image tags and scan on push |
| Service | An ECS Fargate service, 256 CPU units and 512 MiB of memory per task |
| Pipeline | GitHub Actions: test, build, push, deploy, and wait until the service is stable |
| Access | An IAM deploy role that GitHub assumes with OIDC, no stored keys |
| Monitoring | CloudWatch logs kept 14 days, one dashboard and three alarms, with an SNS topic for email |
| State | Terraform state in S3 with KMS encryption, versioning and native locking |
The Terraform code is split into modules (network, alb, ecr, ecs, github_oidc, monitoring) and one environment folder that wires them together. Every value lives in a terraform.tfvars file and no variable has a default, so nothing is hidden in the modules.
How a deploy works
A pull request runs the unit tests and nothing else. A merge to main runs the tests again, builds the Docker image, pushes it to ECR tagged with the commit SHA, registers a new task definition and updates the ECS service. The job waits until the service is stable, so a green run means the new version is serving traffic.
In a live test of the pipeline, a full run from merge to a stable service took 2 minutes 47 seconds, and the page showed the new version number straight after.
Two details make this safe to leave running:
- Rollback is automatic. The service uses the ECS deployment circuit breaker with rollback, so a version that fails its health checks is rolled back instead of taking the app down. See the AWS description of the deployment circuit breaker.
- Images cannot be overwritten. ECR tags are immutable, and each image is tagged with its commit SHA, so you can always tell which code is running and go back to any earlier build.
No stored AWS keys
The pipeline signs in to AWS with OIDC. GitHub issues a short-lived token for each run, and AWS exchanges it for temporary credentials. The only repository variable is the role ARN, which is not a secret.
The role trusts one repository, the main branch and the deploy.yml workflow. It can push to one ECR repository and update one ECS service, nothing else. GitHub changed the format of the token subject for newer repositories, so the trust policy accepts both the plain and the numeric form, and it works whichever the repository uses. The full pattern is in Deploy to AWS from GitHub Actions without access keys, and the GitHub side is described in Configuring OpenID Connect in Amazon Web Services. A different team's move from keys to OIDC is in this case study.
Design choices worth explaining
No NAT gateway. The tasks run in public subnets with a public IP so they can pull images from ECR and write logs. That saves about $32 a month compared with a NAT gateway. The tasks are still not reachable from the internet, because their security group only accepts traffic from the load balancer. A production setup would use private subnets with NAT or VPC endpoints, and the NAT gateway cost post shows the numbers.
Terraform does not manage the running image. The pipeline creates new task definition revisions, so the service ignores changes to the task definition and the desired count. Otherwise the next terraform apply would put the first image back.
Remote state with native locking. State lives in an S3 bucket with KMS encryption, versioning and a TLS-only policy. Locking uses the S3 lockfile, which needs Terraform 1.10 or newer, so there is no DynamoDB table to pay for or maintain. The setup is explained in Terraform remote state on S3 with KMS.
One command to build, one to remove. A Makefile has make up to create everything and make destroy to delete it. For a demo or test environment, that means the bill stops when the work stops.
What you can see once it runs
The CloudWatch dashboard shows requests and errors, response time, healthy and unhealthy targets, ECS CPU and memory, running tasks and the latest application logs on one screen. Three alarms watch for 5xx errors, high CPU and unhealthy targets, and they notify an SNS topic that can send email. This is the same approach as in AWS monitoring alerts before customers do: know before someone tells you.
What I would add for production
This project was scoped as a clean, working base. For a production workload I would add:
- Private subnets, with NAT or VPC endpoints, so tasks have no public IP.
- HTTPS on the load balancer, with a certificate and a redirect from port 80.
- Autoscaling on CPU or request count, instead of a fixed task count.
- A second environment for staging, using the same modules with different values.
None of this needs a redesign, because the modules and the pipeline already separate these concerns. The CIDR ranges were chosen with room for more subnets, as described in VPC CIDR and subnet planning.
Handover
The handover is the code in the client's GitHub repository, a README with the commands and design choices, and the diagram. The walkthrough follows one live change: edit the app, open a pull request, merge it and watch it deploy. If you are choosing between ECS and Kubernetes for your own project, ECS vs EC2 vs EKS is the place to start.
If you want a pipeline like this, or a review of the one you have, the CI/CD pipelines service page has the packages, and the Free AWS Review is the quickest way to start.
Common questions
Why ECS Fargate and not EC2 or EKS?
For a small stateless API there are no servers to patch or size, and nothing runs when the service is stopped. EKS adds a control plane and a lot to learn that this app did not need. The ECS vs EC2 vs EKS comparison on this site goes through when each one fits.
How does the pipeline deploy without AWS access keys?
GitHub Actions asks GitHub for a short-lived token and exchanges it for temporary AWS credentials through OIDC. The deploy role only trusts this repository, the main branch and the deploy workflow, so nothing stored in GitHub can be reused elsewhere.
What happens if a new version is broken?
The ECS service has the deployment circuit breaker with rollback turned on. If the new tasks keep failing their health checks, ECS stops the rollout and returns to the previous task definition, so the app stays up on the old version.
Why does Terraform ignore the task definition?
The pipeline creates a new task definition revision on every deploy. If Terraform managed the image too, the next terraform apply would roll the service back to the first image. The service ignores changes to the task definition and the desired count, and Terraform keeps managing everything else.
Is this setup ready for production?
It is a solid base: remote state, least-privilege deploy role, alarms and automatic rollback. For production I would add private subnets with NAT or VPC endpoints, HTTPS on the load balancer with a certificate, and autoscaling. The README lists what is deliberately kept small.
Originally published on DevOps with Umar. I keep free browser tools for this kind of work there: an AWS savings estimator, a cron expression generator and more in the tools list.
Top comments (0)