Back to Code & Architecture

Document not found, overwrite this content with #not-found slot in <ContentDoc>.

GitLab CI Pipeline — From Commit to Production in 15 Minutes

This is my go-to pipeline structure for deploying containerized services to ECS Fargate. It handles building, testing, security scanning, and deployment with automatic rollback.

Pipeline Architecture

The Pipeline

stages:
  - build
  - test
  - deploy

variables:
  DOCKER_BUILDKIT: "1"
  ECR_REGISTRY: "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
  IMAGE_NAME: "${ECR_REGISTRY}/${CI_PROJECT_NAME}"
  IMAGE_TAG: "${CI_COMMIT_SHORT_SHA}"

# ---------- BUILD STAGE ----------

lint:
  stage: build
  image: node:20-alpine
  script:
    - npm ci --prefer-offline
    - npm run lint
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_BRANCH == "main"

unit-test:
  stage: build
  image: node:20-alpine
  script:
    - npm ci --prefer-offline
    - npm run test:unit -- --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/

docker-build:
  stage: build
  image: docker:24-dind
  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 --cache-from $IMAGE_NAME:latest -t $IMAGE_NAME:$IMAGE_TAG -t $IMAGE_NAME:latest .
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - docker push $IMAGE_NAME:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_BRANCH == "main"

# ---------- TEST STAGE ----------

integration-test:
  stage: test
  image: node:20-alpine
  script:
    - npm ci --prefer-offline
    - npm run test:integration
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_BRANCH == "main"

security-scan:
  stage: test
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL $IMAGE_NAME:$IMAGE_TAG
  allow_failure: false
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# ---------- DEPLOY STAGE ----------

.deploy_template: &deploy_template
  stage: deploy
  image: 
    name: amazon/aws-cli:latest
    entrypoint: [""]
  before_script:
    - yum install -y jq
  script:
    - |
      # Update ECS service with new task definition
      TASK_DEF=$(aws ecs describe-task-definition --task-definition $TASK_FAMILY --region $AWS_REGION)
      
      # Create new task definition revision with updated image
      NEW_TASK_DEF=$(echo $TASK_DEF | jq --arg IMAGE "$IMAGE_NAME:$IMAGE_TAG" \
        '.taskDefinition | .containerDefinitions[0].image = $IMAGE | 
        del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)')
      
      NEW_REVISION=$(aws ecs register-task-definition --region $AWS_REGION --cli-input-json "$NEW_TASK_DEF" | jq -r '.taskDefinition.taskDefinitionArn')
      
      # Update service
      aws ecs update-service \
        --cluster $ECS_CLUSTER \
        --service $SERVICE_NAME \
        --task-definition $NEW_REVISION \
        --region $AWS_REGION
      
      # Wait for deployment to stabilize
      aws ecs wait services-stable \
        --cluster $ECS_CLUSTER \
        --services $SERVICE_NAME \
        --region $AWS_REGION

deploy-staging:
  <<: *deploy_template
  variables:
    ECS_CLUSTER: staging-cluster
    SERVICE_NAME: ${CI_PROJECT_NAME}-staging
    TASK_FAMILY: ${CI_PROJECT_NAME}-staging
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

deploy-production:
  <<: *deploy_template
  variables:
    ECS_CLUSTER: production-cluster
    SERVICE_NAME: ${CI_PROJECT_NAME}-production
    TASK_FAMILY: ${CI_PROJECT_NAME}-production
  environment:
    name: production
    url: https://app.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  when: manual

Key Design Choices

Why this structure works

  1. Parallel build jobs — Lint, tests, and Docker build run concurrently where possible
  2. Cache strategynode_modules cached by branch slug to speed up npm installs
  3. Security gate — Trivy scans block deployment if HIGH/CRITICAL vulnerabilities are found
  4. Manual production deploy — Staging auto-deploys, production requires a human click
  5. ECS wait — Pipeline only succeeds if the deployment actually stabilizes

Rollback Strategy

The ECS deployment circuit breaker handles most rollbacks automatically. For manual rollbacks:

# Quick rollback to previous task definition
aws ecs update-service \
  --cluster production-cluster \
  --service my-service \
  --task-definition my-service:PREVIOUS_REVISION

Results

  • 2 hours → 15 minutes deployment time
  • 250+ deployments per month across all services
  • < 5% change failure rate thanks to automated testing and security scanning
  • Zero manual interventions for rollbacks — circuit breaker handles it