GitLab CI/CD — Complete Pipeline Guide for 2026

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

GitLab CI/CD is one of the most feature-rich pipeline systems available today — and it lives directly inside the same platform as your source code, merge requests, and container registry. Unlike external CI tools, everything from code review to deployment tracking to environment management is in one place.

For teams already on GitLab, leveraging its built-in CI eliminates the overhead of integrating and securing a separate CI service. For teams evaluating options, GitLab CI offers powerful features like review apps, protected environments, and built-in Kubernetes integration that would require multiple tools elsewhere.

Anatomy of .gitlab-ci.yml

Every GitLab pipeline is defined in a .gitlab-ci.yml file at the root of your repository. Here is a production-ready starting point for a Node.js application:

image: node:20
 
stages:
  - install
  - lint
  - test
  - build
  - deploy
 
variables:
  NODE_ENV: test
 
cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/
 
install:
  stage: install
  script:
    - npm ci
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour
 
lint:
  stage: lint
  script:
    - npm run lint
 
test:
  stage: test
  script:
    - npm test -- --coverage
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
 
build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week
  only:
    - main
    - tags

The cache block keyed on package-lock.json ensures the cache is invalidated automatically when dependencies change. Using artifacts to pass node_modules/ between stages is more reliable than relying purely on caching in single-pipeline runs.

Running Services: Databases and Redis

Integration tests often need a real database or cache. GitLab CI launches linked Docker containers as services:

test:integration:
  stage: test
  image: node:20
  services:
    - postgres:16
    - redis:7
 
  variables:
    POSTGRES_DB: testdb
    POSTGRES_USER: testuser
    POSTGRES_PASSWORD: testpassword
    DATABASE_URL: postgresql://testuser:testpassword@postgres/testdb
    REDIS_URL: redis://redis:6379
 
  script:
    - npm ci
    - npm run db:migrate
    - npm run test:integration

Services are reachable by their image name as the hostname (postgres, redis). They start before the job script runs and are torn down after.

Parallel Jobs and Matrix Builds

Jobs in the same stage run in parallel by default. To test across multiple Node versions, use the parallel:matrix keyword:

test:
  stage: test
  parallel:
    matrix:
      - NODE_VERSION: ['18', '20', '22']
  image: node:$NODE_VERSION
  script:
    - npm ci
    - npm test

This spawns three concurrent test jobs, one per Node version, without duplicating YAML. Results from all matrix jobs must pass before the next stage begins.

Environments and Protected Deployments

GitLab tracks deployments per environment, showing history and allowing rollbacks. Protected environments require approvals before deployment proceeds:

deploy:staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.myapp.com
  script:
    - ./deploy.sh staging
  only:
    - develop
 
deploy:production:
  stage: deploy
  environment:
    name: production
    url: https://myapp.com
  when: manual
  script:
    - ./deploy.sh production
  only:
    - main

Setting when: manual on production creates a one-click deployment gate in the GitLab UI. This is the simplest form of deployment approval without external tooling.

Building and Pushing Docker Images

GitLab includes a built-in container registry (registry.gitlab.com). Push images without any external registry credentials:

build:docker:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: '/certs'
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main

CI_REGISTRY, CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, and CI_REGISTRY_IMAGE are predefined GitLab CI variables — no manual secret configuration needed.

Common Mistakes

  • Not setting cache.key properly — Without a meaningful cache key, jobs share stale caches across branches, causing flaky builds.
  • Using only/except instead of rulesonly and except are legacy syntax. The rules keyword is more expressive and should be used in new pipelines.
  • Running all stages on every branch — Use rules or branch filters to skip expensive build and deploy stages on feature branches.
  • Long-running jobs with no timeout — Set timeout per job to prevent stuck runners from consuming quota indefinitely.
  • Hardcoding secrets in .gitlab-ci.yml — Store all sensitive values in GitLab CI/CD Variables (masked + protected) and reference them as environment variables.

Best Practices

  • Define default: image and cache once at the top of your file rather than repeating it in every job.
  • Use needs: to create directed acyclic graphs (DAGs) — this lets later-stage jobs start as soon as their dependencies finish, not after the entire stage.
  • Enable retry: 2 on flaky network-dependent jobs to automatically retry transient failures.
  • Use include files and templates from GitLab's CI template library to avoid reinventing common jobs.
  • Always set expire_in on artifacts to prevent disk usage from growing unboundedly over time.

Key Takeaways

  • GitLab CI/CD pipelines are defined entirely in .gitlab-ci.yml and run natively without external tools.
  • Services (Postgres, Redis, etc.) are linked Docker containers that launch before job scripts and are reachable by image name as hostname.
  • parallel.matrix runs jobs across multiple variable combinations (e.g., Node versions) concurrently without duplicating YAML.
  • GitLab predefined variables (CI_REGISTRY_IMAGE, CI_COMMIT_SHORT_SHA, etc.) eliminate the need for manual secret setup for GitLab-native operations.
  • when: manual on deploy jobs creates a human-in-the-loop gate visible in the GitLab UI before production deployments.
  • The needs: keyword enables DAG-based pipelines where downstream jobs start as soon as their specific dependencies finish, not after the entire stage.
  • Always set artifact expire_in to control disk usage — artifacts without expiry accumulate indefinitely on self-hosted runners.
  • Use rules: instead of legacy only/except for fine-grained control over when jobs run.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading