GitLab CI/CD for AWS ECS: From Code to Production in 8 Minutes
The manual ECS deployment process โ build the Docker image locally, push to ECR, update the task definition, force a new deployment, watch CloudWatch logs โ gets old fast. Automating it properly is the right call after the first couple of deploys.
The GitLab CI/CD pipeline covered here takes a developer's merged commit and delivers it to production ECS Fargate in three stages: build, test, and deploy. The full pipeline completes in 7-8 minutes for the develop branch. Production includes a manual gate โ a human approves the deploy.
This post walks through the complete pipeline configuration, with explanations of every decision.
Repository Branch Strategy
A three-branch model works well for this setup:
| Branch | Environment | Deploy trigger |
|---|---|---|
develop |
dev (ECS dev service) | Automatic on push |
stage |
test (ECS test service) | Automatic on push |
main |
prod (ECS prod service) | Manual gate |
This maps cleanly to GitLab environments and lets teams promote changes through environments explicitly.
The Complete .gitlab-ci.yml
stages:
- build
- test
- deploy
variables:
AWS_REGION: eu-west-2
ECR_REGISTRY: 889604409053.dkr.ecr.eu-west-2.amazonaws.com
ECR_REPO: website
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
# โโ Stage 1: Build Docker image and push to ECR โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- apk add --no-cache aws-cli
- aws ecr get-login-password --region $AWS_REGION |
docker login --username AWS --password-stdin $ECR_REGISTRY
script:
- |
docker build \
--build-arg NODE_ENV=production \
--cache-from $ECR_REGISTRY/$ECR_REPO:cache \
-t $ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG \
-t $ECR_REGISTRY/$ECR_REPO:latest \
.
- docker push $ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG
- docker push $ECR_REGISTRY/$ECR_REPO:latest
# Push cache layer for faster subsequent builds
- docker tag $ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPO:cache
- docker push $ECR_REGISTRY/$ECR_REPO:cache
rules:
- if: $CI_COMMIT_BRANCH =~ /^(develop|stage|main)$/
Why CI_COMMIT_SHORT_SHA as the image tag?
Using the git commit SHA as the image tag gives you:
- Traceability: Every running task in ECS can be traced back to an exact git commit
- Immutability: Tags don't overwrite each other โ
abc1234always means the same image - Rollback: ECS makes it trivial to register a task definition pointing to a previous SHA and deploy it
Push both the SHA tag (for traceability) and latest (for convenience). Never deploy using :latest โ use the SHA.
Layer caching via ECR
The --cache-from $ECR_REGISTRY/$ECR_REPO:cache flag pulls the previous build's layers before building. On a Node.js app where package.json rarely changes, this means the npm install layer is usually a cache hit. Build time drops from ~3 minutes to ~45 seconds for typical code changes.
Stage 2: Test
test:
stage: test
image: node:20-alpine
script:
- npm ci
- npm test
- npm run lint
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
rules:
- if: $CI_COMMIT_BRANCH =~ /^(develop|stage|main)$/
For a website without a heavy test suite, this stage runs basic sanity checks: unit tests, linting, and a smoke test against the Express app. The test stage runs in parallel with the latter half of the build stage where possible.
Stage 3: Deploy โ The Critical Part
The deploy stage has three variants (dev, test, prod) sharing a common script via a YAML anchor:
.deploy_template: &deploy_template
stage: deploy
image: amazon/aws-cli:2.15.0
before_script:
- yum install -y jq
script:
# 1. Get the current task definition
- |
TASK_DEF=$(aws ecs describe-task-definition \
--task-definition $ECS_TASK_FAMILY \
--region $AWS_REGION \
--query 'taskDefinition' \
--output json)
# 2. Update the container image in the task definition
- |
NEW_TASK_DEF=$(echo $TASK_DEF | jq \
--arg IMAGE "$ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG" \
'.containerDefinitions[0].image = $IMAGE |
del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
.compatibilities, .registeredAt, .registeredBy)')
# 3. Register new task definition revision
- |
NEW_REVISION=$(aws ecs register-task-definition \
--cli-input-json "$NEW_TASK_DEF" \
--region $AWS_REGION \
--query 'taskDefinition.taskDefinitionArn' \
--output text)
# 4. Update the ECS service to use the new revision
- |
aws ecs update-service \
--cluster $ECS_CLUSTER \
--service $ECS_SERVICE \
--task-definition $NEW_REVISION \
--region $AWS_REGION
# 5. Wait for rolling deployment to complete
- |
aws ecs wait services-stable \
--cluster $ECS_CLUSTER \
--services $ECS_SERVICE \
--region $AWS_REGION
- echo "Deploy complete: $NEW_REVISION"
deploy:dev:
<<: *deploy_template
variables:
ECS_CLUSTER: gabaltech-cluster
ECS_SERVICE: website-dev
ECS_TASK_FAMILY: website-dev
environment:
name: dev
url: https://dev.gabaltech.co.uk
rules:
- if: $CI_COMMIT_BRANCH == "develop"
deploy:test:
<<: *deploy_template
variables:
ECS_CLUSTER: gabaltech-cluster
ECS_SERVICE: website-test
ECS_TASK_FAMILY: website-test
environment:
name: test
url: https://test.gabaltech.co.uk
rules:
- if: $CI_COMMIT_BRANCH == "stage"
deploy:prod:
<<: *deploy_template
variables:
ECS_CLUSTER: gabaltech-cluster
ECS_SERVICE: website-prod
ECS_TASK_FAMILY: website-prod
environment:
name: prod
url: https://gabaltech.co.uk
when: manual # โ The production gate
rules:
- if: $CI_COMMIT_BRANCH == "main"
The Production Manual Gate
when: manual in GitLab CI is how you require human approval before deploying. The pipeline pauses after test passes and waits for a maintainer to click the play button in the GitLab UI. Only project members with Maintainer role can trigger manual jobs by default โ this is enforced by GitLab's access control, not by a custom auth layer.
Why this matters: automated CI/CD is fast and reliable for dev and test. But production deployments should be a deliberate, accountable action. The manual gate forces someone to say "yes, I am choosing to deploy this to production right now."
IAM Permissions for CI/CD
The GitLab runner needs AWS credentials. Use a dedicated IAM user for CI/CD (not a human user, not the root account) with a scoped policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition",
"ecs:UpdateService",
"ecs:DescribeServices"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::ACCOUNT:role/ecsTaskExecutionRole"
}
]
}
This IAM policy follows least privilege: push to ECR, register and deploy ECS task definitions, and nothing else. The iam:PassRole permission is required for ECS to attach the task execution role to the new task definition revision.
Smoke Test After Deploy
After aws ecs wait services-stable, add a smoke test that hits the health check endpoint:
- |
HEALTH=$(curl -sf https://$DEPLOY_URL/healthz | jq -r '.status')
if [ "$HEALTH" != "ok" ]; then
echo "Smoke test failed: $HEALTH"
exit 1
fi
echo "Smoke test passed"
If the health check fails after a successful ECS deployment, the pipeline fails and alerts the team. ECS has already rolled out the new task โ but the health check catches application-level failures (a broken migration, a missing environment variable) that ECS doesn't know about.
Total Pipeline Time
For a typical code change:
| Stage | Time |
|---|---|
| Build (cache hit) | ~75 seconds |
| Test | ~45 seconds (parallel with build tail) |
| Deploy (wait for stability) | ~90 seconds |
| Total | ~3.5 minutes |
For a change that invalidates npm cache:
| Stage | Time |
|---|---|
| Build (full npm install) | ~3.5 minutes |
| Test | ~45 seconds |
| Deploy | ~90 seconds |
| Total | ~5.5 minutes |
The original target of 8 minutes is conservative โ the pipeline consistently completes under 6 minutes even on full rebuilds.