CI/CD Pipeline with GitHub Actions to ECS Fargate: Build, Push to ECR, and Zero-Downtime Deploys
The first requirement that comes up whenever we set up a continuous deployment pipeline for an ECS Fargate service is "it can't go down while it deploys." It sounds obvious, but getting there means chaining together several pieces that are each simple on their own: build the image, push it to a registry, produce a new task definition revision, and let ECS swap old tasks for new ones without the load balancer ever losing traffic to serve. This post walks through the pipeline we use as a baseline for Node and .NET services on ECS Fargate, with GitHub Actions as the orchestrator.
The shape of the pipeline
The flow has four steps that run in order on every push to main: build the Docker image, push it to Amazon ECR, render a new task definition revision with that image, and update the ECS service to roll out that revision. ECS handles the rolling deployment itself; our job is to feed it the right inputs and configure health checks so it knows a deployment went bad before real traffic ever reaches it.
name: deploy
on:
push:
branches: [main]
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: orders-api
ECS_CLUSTER: prod-cluster
ECS_SERVICE: orders-api-service
CONTAINER_NAME: orders-api
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Log in to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
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"
One thing we changed early on in nearly every project: tag the image with github.sha instead of latest. With latest, the task definition doesn't change between deploys, and ECS never notices there's anything new to roll out, since from its point of view it's still pointing at the same tag. Tagging by commit SHA means every deploy produces a distinct task definition revision, which is exactly what ECS needs to trigger a real rolling deployment.
Credentials without long-lived secrets
The first version of this pipeline we tried used an IAM access key stored as a GitHub secret. It works, but it's a long-lived credential that lives outside AWS and has to be rotated by hand. We replaced it with OIDC: GitHub Actions assumes an IAM role directly, with no access key stored anywhere.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:tuurt/orders-api:ref:refs/heads/main"
}
}
}
]
}
The StringLike condition on sub is the part that actually matters: without it, the role trusts any repository or branch using that OIDC provider, and with GitHub Actions that means trusting any workflow across the entire organization. Scoping it to this repository and to the main branch shrinks the blast radius of a leaked token or a misconfigured workflow in some unrelated repo.
Updating the task definition without hand-rewriting it
Generating the new task definition revision from a base JSON file and swapping out only the image is more reliable than reconstructing the whole JSON inside the pipeline, because it keeps the pipeline from drifting out of sync with changes made by hand to the task definition — new environment variables, adjusted CPU or memory limits, volume mounts someone else added.
- name: Fetch current task definition
run: |
aws ecs describe-task-definition \
--task-definition orders-api-task \
--query taskDefinition > task-definition.json
- name: Fill in the 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@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
describe-task-definition always pulls the latest active revision, so any manual change made through the console or by another pipeline gets carried into the next automated update instead of being silently reverted. wait-for-service-stability: true is the line that ties the pipeline to the actual outcome of the deployment: the GitHub Actions job doesn't go green until ECS confirms the desired count of tasks is running and passing health checks, not just until the update command was sent off.
Health checks: the difference between deploying and deploying without downtime
Without a properly configured health check, ECS can decide a new task is ready the moment the container starts, and begin routing traffic to it before the app has finished opening its database connections or warming its in-memory cache. The container-level health check — not just the load balancer's target group check — is what prevents that:
{
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
"interval": 15,
"timeout": 5,
"retries": 3,
"startPeriod": 30
}
}
startPeriod is the field we most often skipped by mistake in the early iterations of this pipeline: without it, the first health check failures during startup count against the retries limit, and an app that takes 20 seconds to boot can get marked unhealthy before it ever had a real chance to respond. With startPeriod set to 30 seconds, those early checks don't count, and the real retry count only starts once the app has had time to actually initialize.
The /health endpoint itself needs to check real dependencies, not return 200 unconditionally. An endpoint that only confirms the Node process is responding, without checking the database connection, lets traffic through to a task that's technically alive but can't actually complete a real request.
What happens when the new deployment fails
wait-for-service-stability: true is also what surfaces a failed deployment at the right moment. If the new task definition revision can't pass health checks — a missing environment variable, a pending database migration, a config error — ECS keeps trying to stabilize the service for the configured time window, and the GitHub Actions job goes red once that window runs out. That red signal is what triggers the rollback.
Rolling back on ECS Fargate doesn't need any special mechanism: the previous task definition revision still exists, so reverting just means redeploying that specific revision.
aws ecs update-service \
--cluster prod-cluster \
--service orders-api-service \
--task-definition orders-api-task:47 \
--force-new-deployment
What we didn't solve the first time around was doing this automatically: in the first version of the pipeline, a failed deployment left the service in a mixed state — some old tasks, some new ones failing — until someone on the team stepped in by hand with the command above. We ended up adding a follow-up step to the workflow that, if wait-for-service-stability fails, automatically runs update-service against the last known-good revision, saved as an output from an earlier job. It doesn't remove the need to investigate why the deployment failed, but it does remove the minute or five minutes the service used to spend degraded while someone connected to a terminal to roll back manually.
What we left out on purpose
This pipeline doesn't include blue-green deployments through CodeDeploy or canary releases with gradual traffic shifting. For most of the internal services and mid-sized APIs we deploy, a well-configured rolling deployment — real health checks, minimumHealthyPercent at 100 and maximumPercent at 200 so capacity never drops during the swap — covers the zero-downtime requirement without the added operational weight of running a full second environment or traffic-shifting rules. We reserve blue-green for services where a rollback has to be instant at the load balancer level, not just at the task definition level, and that's a call we make per service, not a default we reach for across the board.