There was a time when “deployment” meant someone ran ./deploy.sh on a server while everyone else held their breath in Slack. Tests? Sometimes. Rollback plan? “Revert the commit and pray.”

Then we moved to GitLab CI/CD, and the bar shifted from “did it deploy?” to “did the pipeline go green?” That single .gitlab-ci.yml file in the repo became the contract: every merge request gets validated, every main-branch build is reproducible, and production deploys require a human click — not a human typing credentials into a terminal at midnight.

This is the setup we evolved over several projects in 2018. Nothing exotic — just pipelines that teams actually maintain six months later.

The Minimum Viable Pipeline

Before you add canary deployments and parallel matrix builds across three Node versions, start here. Build, test, deploy — three stages, one YAML file, zero mystery.

# .gitlab-ci.yml
stages:
  - build
  - test
  - deploy

variables:
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker build -t myapp:$CI_COMMIT_SHA .
    - docker tag myapp:$CI_COMMIT_SHA myapp:latest
  only:
    - main
    - develop

test:
  stage: test
  image: node:14
  script:
    - npm install
    - npm run lint
    - npm run test
    - npm run test:e2e
  coverage: '/Lines\s*:\s*(\d+\.\d+)%/'

deploy:
  stage: deploy
  image: alpine:latest
  script:
    - echo "Deploying to production"
    - ./deploy.sh
  only:
    - main
  when: manual

Notice when: manual on production deploy. Automation is great; automatic production deploys on every green build is how you learn what “rollback” really means at 4 PM on a Friday.

Growing Up: A Pipeline That Matches Real Teams

Once the basics work, you’ll want validation before build, security scanning before deploy, and separate staging from production environments. This is the shape we settled on:

stages:
  - validate
  - build
  - test
  - security
  - deploy
  - cleanup

variables:
  NODE_VERSION: "14"
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

# Validate code quality
validate:
  stage: validate
  image: node:$NODE_VERSION
  script:
    - npm ci
    - npm run lint
    - npm run type-check
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/

# Build application
build:
  stage: build
  image: node:$NODE_VERSION
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/

# Run tests
test:
  stage: test
  image: node:$NODE_VERSION
  services:
    - postgres:13
    - redis:6
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: test_user
    POSTGRES_PASSWORD: test_password
  script:
    - npm ci
    - npm run test:unit
    - npm run test:integration
  coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

# Security scanning
security:
  stage: security
  image: node:$NODE_VERSION
  script:
    - npm audit --audit-level=high
    - npm run security:scan
  allow_failure: true

# Deploy to staging
deploy:staging:
  stage: deploy
  image: alpine:latest
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - apk add --no-cache curl
    - curl -X POST https://staging.example.com/deploy \
        -H "Authorization: Bearer $STAGING_TOKEN" \
        -d "image=$DOCKER_IMAGE"
  only:
    - develop

# Deploy to production
deploy:production:
  stage: deploy
  image: alpine:latest
  environment:
    name: production
    url: https://example.com
  script:
    - apk add --no-cache curl
    - curl -X POST https://example.com/deploy \
        -H "Authorization: Bearer $PRODUCTION_TOKEN" \
        -d "image=$DOCKER_IMAGE"
  only:
    - main
  when: manual
  dependencies:
    - build
    - test

The environment blocks aren’t just labels — they give you deployment history in GitLab’s UI, which becomes invaluable when someone asks “what’s running in prod right now?” (It happens more than you’d think.)

Docker: Build Once, Tag Twice, Push to Registry

If you’re containerized, the build stage should produce an image tagged with $CI_COMMIT_SHA (immutable, traceable) and :latest (convenient, slightly dangerous). Push both to GitLab’s container registry so deploy jobs reference a known artifact, not “whatever was on the runner.”

build:docker:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main
    - develop

Pro tip: $CI_COMMIT_SHA in the image tag means you can always trace a running container back to an exact commit. When prod breaks, “what changed?” becomes a git log, not archaeology.

Testing: Split by Speed and Confidence

One giant npm test job feels simple until it takes 20 minutes and developers start pushing with [skip ci]. Split tests by what they’re proving:

Unit Tests — Fast Feedback

test:unit:
  stage: test
  image: node:14
  script:
    - npm ci
    - npm run test:unit -- --coverage
  coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
  artifacts:
    reports:
      junit: junit.xml
    paths:
      - coverage/
    expire_in: 30 days

Integration Tests — Real Dependencies

test:integration:
  stage: test
  image: node:14
  services:
    - postgres:13
    - redis:6
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: test_user
    POSTGRES_PASSWORD: test_password
    DATABASE_URL: postgresql://test_user:test_password@postgres:5432/test_db
  script:
    - npm ci
    - npm run test:integration
  dependencies:
    - build

GitLab’s services spin up sidecar containers — Postgres and Redis on the same network as your test job. No shared staging database getting corrupted by parallel pipelines.

E2E Tests — Slow, Valuable, Screenshot Evidence

test:e2e:
  stage: test
  image: cypress/browsers:node-14.16.0-chrome-90-ff-88
  script:
    - npm ci
    - npm run test:e2e
  artifacts:
    when: always
    paths:
      - cypress/screenshots/
      - cypress/videos/
    expire_in: 1 week

when: always on artifacts is crucial. When E2E fails, you want screenshots — not a job log that says “element not found” with zero context.

Conditional Execution: Run Less, Merge Faster

Not every commit needs the full pipeline. Docs-only changes shouldn’t trigger a 15-minute E2E suite.

# Run only on merge requests
test:mr:
  stage: test
  script:
    - npm test
  only:
    - merge_requests

# Run on specific branches
deploy:feature:
  stage: deploy
  script:
    - echo "Deploying feature branch"
  only:
    - /^feature\/.*$/

# Skip on specific paths
test:
  stage: test
  script:
    - npm test
  except:
    changes:
      - "docs/**/*"
      - "*.md"

# Run only when files change
build:
  stage: build
  script:
    - npm run build
  only:
    changes:
      - "src/**/*"
      - "package.json"

We learned the hard way that overly aggressive only/except rules cause “works on my branch, broke on main” surprises. Use path filters for obvious wins (markdown, assets); keep core test stages on main protected.

Parallel Jobs: Matrix Builds Without the Headache

Need to verify Node 12, 14, and 16 compatibility? GitLab’s parallel matrix runs the same job with different variables:

test:parallel:
  stage: test
  image: node:14
  parallel:
    matrix:
      - NODE_VERSION: ["12", "14", "16"]
  script:
    - nvm use $NODE_VERSION
    - npm ci
    - npm test

Fair warning: matrix builds multiply runner minutes. Use them where version compatibility actually matters, not “because we can.”

Deployment Strategies That Don’t Bet the Company

Blue-Green: Two Environments, One Switch

Deploy to the inactive environment, verify health, flip traffic. If green is sick, blue is still serving.

deploy:blue-green:
  stage: deploy
  image: alpine:latest
  script:
    - apk add --no-cache curl jq
    - |
      # Deploy to green environment
      GREEN_URL="https://green.example.com"
      curl -X POST "$GREEN_URL/deploy" \
        -H "Authorization: Bearer $DEPLOY_TOKEN" \
        -d "image=$DOCKER_IMAGE"
      
      # Wait for health check
      sleep 30
      HEALTH=$(curl -s "$GREEN_URL/health")
      
      if [ "$(echo $HEALTH | jq -r '.status')" = "healthy" ]; then
        # Switch traffic to green
        curl -X POST "https://lb.example.com/switch" \
          -H "Authorization: Bearer $LB_TOKEN" \
          -d "target=green"
      else
        echo "Health check failed"
        exit 1
      fi
  only:
    - main

Canary: Trust, But Verify With Metrics

Roll out to 10% of traffic, watch error rates, expand or abort. This is the “we’re reasonably confident but not suicidal” strategy.

deploy:canary:
  stage: deploy
  image: alpine:latest
  script:
    - |
      # Deploy canary (10% traffic)
      curl -X POST "https://api.example.com/deploy" \
        -H "Authorization: Bearer $DEPLOY_TOKEN" \
        -d "image=$DOCKER_IMAGE&traffic=10"
      
      # Monitor metrics
      sleep 300
      
      # Check error rate
      ERROR_RATE=$(curl -s "https://metrics.example.com/error-rate")
      
      if [ "$ERROR_RATE" -lt "1" ]; then
        # Increase to 50%
        curl -X POST "https://api.example.com/traffic" \
          -d "traffic=50"
        
        sleep 300
        
        # Full rollout
        curl -X POST "https://api.example.com/traffic" \
          -d "traffic=100"
      else
        echo "Canary deployment failed"
        exit 1
      fi

The sleep 300 blocks are simplified — in production you’d poll metrics with a timeout and clear abort criteria. But the shape is right: deploy small, measure, then commit.

Kubernetes Deploys From CI

If you’re on K8s, the deploy job is often just “update the image and wait for rollout”:

deploy:k8s:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - kubectl config use-context $KUBE_CONTEXT
    - kubectl set image deployment/myapp \
        myapp=$DOCKER_IMAGE \
        -n production
    - kubectl rollout status deployment/myapp -n production
  only:
    - main

Store KUBE_CONTEXT and kubeconfig as protected CI variables. Never commit credentials — GitLab’s variable masking exists for a reason.

Secrets, Caching, and Artifacts: The Plumbing That Matters

Environment variables belong in GitLab CI/CD Settings → Variables, marked protected and masked for anything sensitive:

# Set in GitLab CI/CD Settings > Variables
variables:
  NODE_ENV: production
  AWS_REGION: us-east-1

deploy:
  stage: deploy
  script:
    - echo "Deploying with $NODE_ENV"
    - aws s3 sync dist/ s3://$S3_BUCKET
  environment:
    name: production
    url: https://example.com

Caching turns 8-minute npm ci runs into 2-minute ones. Cache per branch slug so feature branches don’t pollute each other:

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/
    - .npm/

build:
  stage: build
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run build
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/
      - .npm/
    policy: pull-push

Artifacts pass build output between stages without rebuilding:

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
      - build/
    expire_in: 1 week
    reports:
      dotenv: build.env

deploy:
  stage: deploy
  script:
    - ls -la dist/
  dependencies:
    - build

Set job timeouts on anything that touches Docker or E2E — a hung docker:dind job will consume a runner until someone notices.

What Actually Makes Pipelines Survive

The teams whose pipelines still work a year later share a few habits. Stages mirror the development flow — validate before build, test before deploy — not arbitrary alphabet soup. Dependencies get cached aggressively; secrets never touch the repo. Production deploys stay manual or gated until you’re confident in rollback. Pipeline failures get the same respect as production incidents — a red main branch is a blocked team.

Start with build → test → deploy. Add complexity when pain demands it, not when a blog post (even this one) suggests it.

The Bottom Line

GitLab CI/CD turned deployment from a ritual into a repeatable process. Your .gitlab-ci.yml is documentation that actually runs — every merge request proves the code works, every production deploy traces to a commit SHA, and nobody SSHs into servers at midnight unless something has gone very wrong.

That’s the goal: boring deploys. The exciting ones are the ones you read about on Hacker News, not the ones you live through.


Written August 2018, covering GitLab 11.0+ CI/CD features. GitLab’s syntax and runner images have evolved since — check the current docs for rules vs only/except and updated Node LTS versions.