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
- Parallel build jobs — Lint, tests, and Docker build run concurrently where possible
- Cache strategy —
node_modulescached by branch slug to speed up npm installs - Security gate — Trivy scans block deployment if HIGH/CRITICAL vulnerabilities are found
- Manual production deploy — Staging auto-deploys, production requires a human click
- 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