DevOps

GitLab CI/CD for AWS ECS: From Code to Production in 8 Minutes

2024-08-20 Gabor Balogh

GitLabCI/CDAWS ECSECRDockerFargate

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:

  1. Traceability: Every running task in ECS can be traced back to an exact git commit
  2. Immutability: Tags don't overwrite each other โ€” abc1234 always means the same image
  3. 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.

Back to Blog

About the Author

Gabor Balogh is a senior cloud platform engineer and DevOps consultant building infrastructure-as-code and developer platforms.