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.19Tags 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.shKeying 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: dependA 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_inon large artifacts — they default to never expiring on some project settings and quietly eat your storage quota.Mixing
only/exceptwithruleson 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: rulesat the top level to prevent duplicate pipelines.Assuming
cacheguarantees the files are there — treat it as an optimization, not a dependency mechanism. Useartifactsfor 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:dindservices correctly — Docker-in-Docker jobs needDOCKER_TLS_CERTDIRconfigured or they silently fail to connect on newer runner images.