How to Deploy to AWS ECS Fargate with GitHub Actions: A Plain-English Guide for Startup Engineers
Getting a CI/CD pipeline wired up between GitHub and AWS ECS Fargate is one of those tasks that takes two hours if you understand what's happening, and two days if you're copying YAML from Stack Overflow without knowing why each piece is there.
Most tutorials go straight to the workflow file. This one doesn't. We'll explain the pipeline from first principles first — what each component is, why it exists, and how the pieces connect. Then we'll walk through the actual setup.
If you've outgrown Railway and you're running on ECS Fargate (or you're planning to), this guide is for you.
The pipeline in plain English
Here's what happens when you push code to main and a CI/CD pipeline is running:
- GitHub Actions triggers on your push.
- Your app is built into a Docker image. The image is a self-contained snapshot of your app and its dependencies.
- The image is pushed to Amazon ECR (Elastic Container Registry). ECR is AWS's private Docker image registry — think Docker Hub, but private and inside your AWS account.
- ECS is told to update the running service to use the new image.
- ECS performs a rolling deploy — new containers come up with the new image, old containers drain and stop. Zero downtime if configured correctly.
That's the whole pipeline. Every YAML line in the workflow file is doing one of these five things.
The components you need to know
Docker image
Your app runs inside a container. A container is an instance of a Docker image. The image defines everything: your runtime (Node.js, Python, Go, etc.), your app code, your dependencies, your startup command.
You define the image in a Dockerfile. Here's a minimal one for a Node.js app:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
Every CI run builds a new image from this file.
Amazon ECR
ECR stores your Docker images. Each image is tagged — typically with the Git commit SHA — so you can trace exactly which code version is running.
123456789.dkr.ecr.ap-south-1.amazonaws.com/my-app:a3f92b1
That tag (a3f92b1) is the first 7 characters of the commit SHA. When something breaks in production, you can instantly trace it to the exact commit.
ECS Task Definition
An ECS task definition is a JSON document that describes a container: which image to run, how much CPU and memory to allocate, which port to expose, which environment variables to inject, and which IAM role to use.
When you deploy a new image, you create a new task definition revision that points to the new ECR image tag. ECS registers the revision and uses it to launch new containers.
ECS Service
The ECS service is the long-running process that keeps your app running. It maintains the desired count of tasks (e.g., 2 containers), handles health checks, and performs rolling deployments when the task definition is updated.
When you trigger a deploy, you're telling the service: "update to this new task definition revision." ECS does the rest.
Setting up the pipeline
Step 1: Create an ECR repository
In the AWS console (or via CLI):
aws ecr create-repository --repository-name my-app --region ap-south-1
Note the repository URI — you'll use it in the workflow.
Step 2: Create an IAM role for GitHub Actions
GitHub Actions needs permission to push to ECR and update ECS. Use OIDC for this — it generates short-lived credentials per workflow run instead of long-lived secrets you have to rotate.
In AWS IAM, create an OIDC identity provider:
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
Create an IAM role with a trust policy allowing GitHub Actions to assume it, scoped to your repo:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
Attach a policy with the minimum permissions needed:
ecr:GetAuthorizationTokenecr:BatchCheckLayerAvailabilityecr:PutImageecr:InitiateLayerUpload/UploadLayerPart/CompleteLayerUploadecs:RegisterTaskDefinitionecs:UpdateServiceecs:DescribeServicesecs:DescribeTaskDefinitioniam:PassRole(for the ECS task execution role)
Store the IAM role ARN as a GitHub Actions secret: AWS_ROLE_ARN.
Step 3: The GitHub Actions workflow
Create .github/workflows/deploy.yml:
name: Deploy to ECS
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
env:
AWS_REGION: ap-south-1
ECR_REPOSITORY: my-app
ECS_CLUSTER: my-cluster
ECS_SERVICE: my-service
CONTAINER_NAME: my-app
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image to ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Download current task definition
run: |
aws ecs describe-task-definition \
--task-definition ${{ env.ECS_SERVICE }} \
--query taskDefinition \
> task-definition.json
- name: Update task definition with new image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
What each step does
- Checkout — pulls your code into the runner
- Configure AWS credentials — assumes the IAM role via OIDC; no stored keys
- Login to ECR — authenticates Docker to push to your private registry
- Build, tag, push — builds the Docker image, tags it with the commit SHA, pushes to ECR
- Download task definition — fetches the current task definition JSON from ECS (so you're updating it, not replacing it)
- Render task definition — replaces the image URI in the task definition with the new ECR tag
- Deploy — registers the new task definition revision, updates the ECS service, waits for stability
wait-for-service-stability: true means the workflow step won't succeed until the new containers are healthy and the old ones have drained. This is how you catch deploy failures in CI rather than finding out from users.
Common mistakes that will cost you time
Not scoping the OIDC trust policy to a specific branch
If you don't scope the OIDC trust to refs/heads/main, any branch can assume the deploy role. Lock it down.
Updating environment variables outside the task definition
Environment variables in ECS are part of the task definition. If you change them in the console, they get overwritten on the next deploy. Manage all env vars in your task definition (or via AWS AppConfig/Secrets Manager for dynamic values).
Not setting resource limits on your task
Fargate bills for CPU and memory reserved, not consumed. An undersized task gets throttled; an oversized one wastes money. Profile your app's actual usage and set limits accordingly.
Using latest as the image tag
Tagging every image as latest makes rollbacks impossible and breaks traceability. Always use the commit SHA.
wait-for-service-stability: false
The workflow will mark the deploy as successful before ECS has confirmed the new containers are healthy. Your pipeline shows green while production is broken. Always set this to true.
What NoahOps does for you
The pipeline above works. Setting it up takes a couple of hours for an engineer who knows AWS well. For a startup team that doesn't live in the AWS console, it takes longer — and the ongoing maintenance (IAM policy updates, task definition management, monitoring rollback triggers) adds up.
NoahOps automates this pipeline and adds a layer on top: Noah AI lets you trigger deploys, roll back, and manage environments in plain English. The GitHub integration, ECR push, ECS rolling deploy, and rollback logic are all pre-wired. You describe what you want to happen; NoahOps executes it against your own AWS account.
You own your infrastructure. NoahOps just removes the setup and maintenance overhead.
Request a free demo at noahops.com
Frequently Asked Questions
Do I need a DevOps engineer to set this up?
No, but it helps to have someone who's comfortable in the AWS console for the initial IAM and ECS configuration. Once the pipeline is set up, developers can push and deploy without touching AWS.
Can I use this for multiple environments (staging + production)?
Yes. Use separate ECS clusters and services per environment, and trigger the workflow conditionally — pushes to main deploy to staging, tagged releases deploy to production.
What's the difference between ECS and Fargate?
ECS is the orchestration layer — it manages your containers. Fargate is the compute mode — it runs containers without you managing EC2 instances. When people say "ECS Fargate," they mean ECS using Fargate as the compute backend. The alternative is EC2 launch type, where you manage the underlying instances yourself. For most startups, Fargate is the right choice.
How do I handle database migrations in this pipeline?
Run migrations as a separate ECS task before the service update, or as a step in the GitHub Actions workflow (using the same Docker image, different command). Never run migrations inside the container startup — if the container restarts, the migration runs again.
What happens if the deploy fails?
With wait-for-service-stability: true, ECS will automatically roll back to the previous task definition revision if the new containers fail health checks. The workflow step also fails, so you see the failure in GitHub Actions.