All topics
DevOps · Learning hub

GitLab CI notes for developers

Master GitLab CI with a curated set of 1 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallTest yourself — GitLab CI quizMore DevOps notes
GitLab CI

GitLab CI Essentials

GitLab CI Essentials GitLab CI/CD is the pipeline system built directly into GitLab. Instead of wiring up a separate CI provider, you commit a single .gitlab-ci

GitLab CI Essentials

GitLab CI/CD is the pipeline system built directly into GitLab. Instead of wiring up a separate CI provider, you commit a single .gitlab-ci.yml file to the root of your repo and GitLab's runners pick it up on every push, merge request, tag, or schedule. Because it lives next to the code and the GitLab API, it's a natural fit when you're already hosting on GitLab — merge request pipelines, environments, and deployments are all first-class concepts rather than bolted-on integrations.

.gitlab-ci.yml Structure: Stages & Jobs

A pipeline is made of stages (ordered phases like build → test → deploy) that run sequentially, and jobs (the actual units of work) that run in parallel within a stage. Every job needs a script — that's the only required key.

stages:
  - build
  - test
  - deploy

variables:
  NODE_ENV: "production"

build_job:
  stage: build
  image: node:20-alpine
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

unit_tests:
  stage: test
  image: node:20-alpine
  script:
    - npm ci
    - npm test -- --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'

lint:
  stage: test
  image: node:20-alpine
  script:
    - npm ci
    - npm run lint

deploy_staging:
  stage: deploy
  image: alpine:3.19
  script:
    - apk add --no-cache curl
    - curl -X POST "$DEPLOY_HOOK_URL"
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Both test jobs run in parallel because they share the same stage — GitLab waits for all jobs in a stage to finish (or fail) before moving to the next stage. If you don't declare stages explicitly, GitLab defaults to the built-in .pre → build → test → deploy → .post sequence.

Runners

Runners are the agents that actually execute job scripts. GitLab.com provides shared runners out of the box (SaaS, Linux/Docker executor by default, plus macOS and Windows runner fleets), but you can also register your own — self-hosted, on-prem, or in your own cloud account — for more control over hardware, caching, or network access to internal resources.

# Target a specific runner by tag — useful when you have both
# shared and self-hosted runners registered on the project
deploy_prod:
  stage: deploy
  tags:
    - self-hosted
    - linux
  script:
    - ./deploy.sh production
  rules:
    - if: '$CI_COMMIT_TAG'

# Register a self-hosted runner (run once on the host machine):
#   gitlab-runner register \
#     --url https://gitlab.com/ \
#     --registration-token "$RUNNER_TOKEN" \
#     --executor docker \
#     --docker-image alpine:3.19

Tags are how you route jobs to the right runner — a job with no tags can land on any untagged runner, which is usually fine for shared runners but a problem the moment you register a self-hosted one that only some jobs should use.

Caching & Artifacts

These two look similar but solve different problems. cache speeds up jobs by reusing dependency directories (like node_modules) across pipeline runs — it's best-effort and may miss. artifacts pass files between jobs and stages in the same pipeline, and are guaranteed to exist for downstream jobs that need them (e.g. handing a build output to a deploy job).

default:
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull-push

build:
  stage: build
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    reports:
      junit: junit.xml
    expire_in: 30 days

deploy:
  stage: deploy
  needs: ["build"]
  script:
    - ls dist/  # artifact from the build job is available here
    - ./deploy.sh

Keying the cache on package-lock.json means the cache only gets a fresh key when dependencies actually change — everyday commits reuse the same cache instead of rebuilding node_modules from scratch. Set policy: pull on jobs that only read the cache (like test jobs) so they don't waste time re-uploading it.

CI/CD Variables

Variables can be defined in .gitlab-ci.yml, in the project's Settings → CI/CD → Variables (for secrets — mark them Protected and Masked), or predefined by GitLab itself (CI_COMMIT_BRANCH, CI_PIPELINE_SOURCE, CI_MERGE_REQUEST_IID, etc.). Protected variables only get injected into pipelines running on protected branches/tags, so a feature branch pipeline can't leak a production deploy token.

variables:
  IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

build_image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_TAG .
    - docker push $IMAGE_TAG
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: never
    - if: '$CI_COMMIT_BRANCH == "main"'

Advanced Pipeline Patterns

Once a pipeline grows past a handful of jobs, three features matter most: needs to break out of strict stage ordering, extends to reuse job config with YAML anchors' cousin, and include to split config across files.

include:
  - local: '.gitlab/ci/test.yml'
  - template: 'Security/SAST.gitlab-ci.yml'

.node_job_base:
  image: node:20-alpine
  before_script:
    - npm ci

unit_tests:
  extends: .node_job_base
  stage: test
  script:
    - npm test
  needs: []  # runs immediately, doesn't wait for earlier stages

integration_tests:
  extends: .node_job_base
  stage: test
  script:
    - npm run test:integration
  needs:
    - job: build_job
      artifacts: true

# Parent-child pipelines — trigger a separate pipeline from a job
trigger_deploy_pipeline:
  stage: deploy
  trigger:
    include: .gitlab/ci/deploy.yml
    strategy: depend

A job with needs: [] starts as soon as the pipeline begins, regardless of its declared stage — this is how you build a true DAG instead of waiting on every job in every earlier stage. rules (the modern replacement for only/except) lets you control both whether a job runs and what triggers it, evaluated top to bottom with the first match winning.

Common Pitfalls

  • Forgetting expire_in on large artifacts — they default to never expiring on some project settings and quietly eat your storage quota.

  • Mixing only/except with rules on the same job — GitLab rejects this; pick one syntax per job (rules is the current, more flexible choice).

  • A pipeline running twice for the same push — once for the branch, once for the merge request — because both push and merge_request_event rules match. Add workflow: rules at the top level to prevent duplicate pipelines.

  • Assuming cache guarantees the files are there — treat it as an optimization, not a dependency mechanism. Use artifacts for anything a later job actually needs to function.

  • Putting secrets directly in .gitlab-ci.yml — it's committed to the repo. Always use masked/protected CI/CD variables in project settings instead.

  • Not scoping docker:dind services correctly — Docker-in-Docker jobs need DOCKER_TLS_CERTDIR configured or they silently fail to connect on newer runner images.

Keep your GitLab CI knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever