GitLab CI/CD
Pipelines defined in .gitlab-ci.yml: stages, jobs, runners and environments built into GitLab.
In 30 seconds
GitLab CI/CD is the automation engine built into GitLab. A file called .gitlab-ci.yml in the repository describes a pipeline: jobs grouped into stages such as build, test and deploy. GitLab Runners, on GitLab's infrastructure or your own servers, execute those jobs, usually inside Docker containers. Environments, approvals and deployment history are part of the same product, so code, merge requests and deploys are all in one place.
Not in my daily stack — I know the concepts and would learn the details when a project needs it.
Why it matters
- It is one product for the whole lifecycle: repository, merge requests, pipelines, container registry, environments and security scans, which suits teams that want fewer tools.
- Self-managed GitLab plus your own runners is a mature option for companies that must keep code and builds on their own infrastructure.
- Pipelines are just YAML with includes, templates and CI/CD components, so a platform team can standardise pipelines across hundreds of projects.
- Environments track what is deployed where, with manual gates, protected environments and one-click rollback to a previous deployment.
How it works
01Pipelines, stages and jobs
A pipeline is the full run triggered by a push, a merge request, a tag, a schedule or an API call. It is made of jobs, and each job is a named block in .gitlab-ci.yml with a script: the shell commands to run. Jobs are grouped into stages, listed in order under stages:. All jobs in a stage run in parallel; the next stage starts only when every job in the current stage succeeded. Picture an assembly line with stations: every worker at the build station must finish before the parts move to the test station. Each job runs in a fresh environment, often a Docker container chosen with image:, so files do not carry over unless you declare them as artifacts or cache. If no stage is given, a job lands in test. The predefined stages .pre and .post always run first and last.
stages: [build, test, deploy]
default:
image: node:24-alpine
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week
unit-tests:
stage: test
script:
- npm ci
- npm test
02Runners and executors
A GitLab Runner is an agent program that asks GitLab for jobs and executes them. On GitLab.com, hosted runners are available on Linux, Windows and macOS and billed in compute minutes. Self-managed runners are installed on your own machines; you register them with an authentication token created in the project, group or instance settings. The executor decides how a job runs: shell runs directly on the host (simple but no isolation), docker starts a container per job from the job's image (the common default), kubernetes starts a pod per job in a cluster, and docker-autoscaler or instance executors create cloud VMs on demand. Tags connect jobs to runners: a job with tags: [gpu] only runs on runners registered with that tag. Runners can be instance-wide, group-level or project-specific, which is how you keep a production-network runner available only to the deploy project.
03Controlling when jobs run: rules and workflow
rules: decides whether a job is added to a pipeline. Rules are checked top to bottom and the first match wins; each can set when: (on_success, manual, delayed, never) and allow_failure. Conditions use predefined variables such as CI_PIPELINE_SOURCE, CI_COMMIT_BRANCH and CI_DEFAULT_BRANCH, plus changes: for file paths and exists: for files. workflow: rules works one level higher and decides whether a pipeline is created at all. A common pattern is to run pipelines for merge requests and for the default branch, but not a duplicate branch pipeline for the same commit. Prefer rules over the older only/except keywords, which are no longer recommended. needs: lets a job start as soon as specific jobs finish, ignoring stage order, which turns the pipeline into a faster directed graph.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
e2e-tests:
stage: test
needs: [build]
rules:
- changes: [src/**/*, package-lock.json]
script:
- npm run test:e2e
04Variables, secrets and environments
CI/CD variables are set in project, group or instance settings, or in the YAML for non-sensitive values. Mark sensitive ones as masked (hidden in logs) and protected (only available to pipelines on protected branches and tags), so a feature branch cannot read production credentials. File-type variables write the value to a temporary file and put its path in the variable, which suits kubeconfigs and certificates. An environment is a named target such as staging or production; jobs declare environment: name and url, and GitLab records each deployment, so you see what is running where and can roll back. Protected environments restrict who can deploy, and deployment approvals add required approvers. For cloud access, GitLab issues OIDC ID tokens (id_tokens:) that clouds and HashiCorp Vault accept, which avoids long-lived keys entirely.
05Building images and deploying
GitLab ships a container registry per project and predefined variables to use it: CI_REGISTRY, CI_REGISTRY_IMAGE and a job token for login. The job below builds an image with Docker-in-Docker, tags it with the commit SHA and pushes it. Docker-in-Docker requires a privileged runner; on Kubernetes or shared runners where that is not allowed, rootless builders such as Buildah or BuildKit are common alternatives. The deploy job is manual on the default branch, linked to the production environment, and uses resource_group so two deploys can never run at the same time. The deploy command here is a placeholder: it might be a helm upgrade, an SSH command that pulls the image, or a cloud CLI call.
build-image:
stage: build
image: docker:28
services: [docker:28-dind]
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
deploy-prod:
stage: deploy
environment: { name: production, url: https://example.com }
resource_group: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
script:
- ./scripts/deploy.sh "$CI_COMMIT_SHA"
Use it when
- Your code is hosted in GitLab, on GitLab.com or self-managed.
- You need everything on your own infrastructure: self-managed GitLab with self-managed runners in your network.
- A platform team wants to standardise pipelines across many projects with includes and CI/CD components.
- You value built-in environments, deployment history, approvals and a per-project container registry.
Skip it when
- Your repositories live on GitHub; GitHub Actions integrates more naturally.
- A tiny static site with no tests, where a hosting platform's git-push build is simpler.
- You need exotic build orchestration better served by a dedicated system such as Buildkite or Bazel remote execution.
Alternatives
- GitHub Actions
The equivalent for GitHub repositories, with a large Marketplace of actions.
- Jenkins
Self-hosted, plugin-driven; often found in older or highly customised setups.
- Argo CD / Flux
GitOps tools that pull deploys into Kubernetes, often paired with GitLab CI for builds.
- Tekton
Kubernetes-native pipeline building blocks for teams building their own CI platform.
Best practices
- Use rules: and workflow: rules, not only/except, and avoid duplicate branch plus merge request pipelines.
- Mark credentials as masked and protected, and protect the branches and environments that deploy.
- Use needs: to build a directed graph so jobs start as soon as their inputs are ready.
- Share pipeline logic through include: or CI/CD components with typed inputs instead of copy-paste.
- Pin image tags (node:24-alpine, not latest) and cache dependencies keyed by the lockfile.
- Use resource_group on deploy jobs so deployments to one environment never overlap.
- Run deploy jobs only on dedicated, tagged runners that sit close to the target and nowhere else.
Common mistakes
- Leaving production variables unprotected, so any branch pipeline can read and leak them.
- Registering a shell executor runner on a shared host, where jobs can touch each other's files and the host.
- Confusing cache and artifacts, then wondering why a build output is missing in the next stage.
- Echoing secrets or running scripts with set -x, printing values that masking does not catch.
- Huge single-file pipelines with copied jobs instead of extends:, anchors or includes.
Deep dive
06CI/CD components and typed inputs
A CI/CD component is a reusable unit of pipeline configuration stored in a component project under templates/, released with semantic version tags and listed in the CI/CD Catalog. The file begins with a spec: header that declares inputs with type (string, number, boolean, array), default, options and regex validation, separated from the job definitions by a --- line. Values are interpolated with $[[ inputs.name ]] when the pipeline is created, before any job runs, so a wrong value fails fast with a clear error rather than mid-pipeline. Consumers include it with include: component: and a version, which can be an exact tag, a partial version such as ~latest, or a commit SHA. Use exact versions or SHAs for production pipelines, and let a bot propose upgrades. Components are the GitLab equivalent of reusable workflows, and the basis for an internal pipeline catalogue.
# templates/node-test/template.yml in the component project
spec:
inputs:
node_version:
default: "24"
stage:
default: test
---
node-test:
stage: $[[ inputs.stage ]]
image: node:$[[ inputs.node_version ]]-alpine
script:
- npm ci
- npm test
# consumer .gitlab-ci.yml
include:
- component: $CI_SERVER_FQDN/your-group/ci-components/[email protected]
inputs: { node_version: "22" }
07Parent-child and multi-project pipelines
Large repositories hit limits of a single pipeline: too many jobs, slow to read, everything runs for every change. A parent-child pipeline lets a job trigger a separate child pipeline from another YAML file in the same project, with trigger: include:. Combined with rules: changes:, a monorepo can run only the child pipeline for the service that changed. Children can even be generated dynamically: a job writes a YAML file as an artifact and a trigger job runs it, which is how teams create pipelines per detected package. Multi-project pipelines trigger a pipeline in another project, for example an application pipeline that triggers the deployment repository's pipeline after publishing an image. strategy: depend (or the newer mirror strategy) makes the trigger job reflect the downstream result, so a failure in the child shows up in the parent.
08Runner fleet scaling and isolation
Queue time is the hidden cost of CI. Each runner has concurrent (total jobs for the process) and per-runner limit settings in config.toml; set them according to CPU and memory, not hope. For bursty load, the Kubernetes executor or the docker-autoscaler executor with a cloud fleeting plugin scales out on demand and back to zero. Separate fleets by trust level: shared runners for ordinary builds, protected runners (which only take jobs from protected branches) for deploys, and network-isolated runners for anything touching production. Privileged mode for Docker-in-Docker gives a job near-root access to the host; avoid it on shared fleets and prefer rootless builders. Distributed cache in S3-compatible storage lets autoscaled runners share cache. Watch the runner's Prometheus metrics for job duration, queue time and errors.
Real-world scenarios
A monorepo where every commit runs everything
Problem
A repository holds a web app, an API and three workers. Every commit runs all 60 jobs for 40 minutes, even for a one-line change in one service.
How to solve it
Give each service its own child pipeline file, for example services/api/.gitlab-ci.yml. In the root file, add one trigger job per service with trigger: include: that file and strategy: depend, plus rules: changes: services/api/**/* and shared paths such as the lockfile. Keep a full run on the default branch nightly via a scheduled pipeline to catch cross-service breakage. Add needs: inside child pipelines to shorten critical paths. A typical change now runs one child pipeline in a few minutes, and the parent view still shows the status of each service clearly.
A feature branch leaked a production token
Problem
A developer added a debug step on a feature branch that printed environment variables, and a production API token appeared base64-encoded in the job log.
How to solve it
Revoke and rotate the token immediately, then erase the job log. Mark the new variable as protected and masked, so it is only injected on protected branches and hidden in logs. Protect the main branch and the production environment, and move deploy credentials to a protected environment scope. Restrict deploy jobs to a protected runner. Longer term, replace the static token with id_tokens and a short-lived credential from Vault or the cloud provider. Add a short team note: never print environment variables in CI.
Two deploys ran at once and corrupted a migration
Problem
Two merges landed a minute apart. Both deploy jobs ran in parallel, both started database migrations, and production ended in a half-migrated state.
How to solve it
Add resource_group: production to the deploy job so GitLab runs only one deploy to that environment at a time; set process_mode to oldest_first or newest_first depending on whether every commit must deploy. Split migrations into their own job before the app rollout, and make them backward compatible (expand, then contract) so old and new code can run during the switch. Enable the option to prevent outdated deployment jobs so an older pipeline cannot deploy over a newer one. Record each deploy in the production environment for easy rollback.
Cheat sheet
- Validate config locally via API
glab ci lint .gitlab-ci.yml- Run only for merge requests
rules: [{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]- Manual job on the default branch
rules: [{ if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH', when: manual }]- Start early, skip stage order
needs: [build]- Cache keyed by lockfile
cache: { key: { files: [package-lock.json] }, paths: [.npm/] }- One deploy at a time
resource_group: production- OIDC token for a job
id_tokens: { CLOUD_TOKEN: { aud: https://example.com } }- Reuse a hidden template job
.base: { image: node:24-alpine } then test: { extends: .base }- Register a runner
gitlab-runner register --url https://gitlab.example.com --token $RUNNER_TOKEN
Questions & answers
Q1What is the difference between stages and needs?
Stages give a simple linear order: all jobs in one stage must finish before the next stage starts. That is easy to read, but slow when one job in a stage is much longer than the others, because everything waits for it. needs: declares direct dependencies between jobs, so a job starts as soon as the jobs it needs have finished, even if other jobs in earlier stages are still running. The pipeline becomes a directed acyclic graph. needs also controls which artifacts a job downloads. I start with stages for clarity and add needs where the critical path is long, for example letting deploy-docs start as soon as build-docs finishes.
Q2Cache versus artifacts in GitLab?
Artifacts are job outputs that GitLab stores and passes to later jobs in the same pipeline, and that you can download from the UI: build folders, test reports, coverage. They are reliable; a later job that needs them will get them. Cache is a best-effort speed-up for things you can regenerate, such as node_modules or a package manager's download directory, shared between pipelines and stored per runner or in object storage. A job must work correctly with an empty cache. Use cache:key:files with the lockfile so the cache changes when dependencies change. If a job's correctness depends on the file, use an artifact.
Q3How do you protect production credentials in GitLab CI?
Store them as CI/CD variables marked protected and masked, so they are only injected into pipelines running on protected branches or tags and are hidden in logs. Protect the main branch so only maintainers can push or merge. Make production a protected environment with deployment approvals, so only named people or groups can run deploy jobs. Run deploy jobs on a dedicated tagged runner in the production network, not on shared runners. Where possible, avoid static credentials entirely: use id_tokens to give the job a short-lived OIDC token that your cloud provider or Vault exchanges for temporary credentials limited to that project and branch.
Q4What is a GitLab Runner executor and which would you choose?
The executor is how the runner actually runs a job. Shell runs commands directly on the runner host: fastest to set up, but no isolation between jobs, so I only use it for very trusted single-purpose machines. Docker runs every job in a fresh container from the job's image: good isolation, reproducible, and the usual default. Kubernetes creates a pod per job, which fits teams already running a cluster and wanting autoscaling. Docker-autoscaler or instance executors start cloud VMs on demand for strong isolation per job. For most teams I would pick the Docker executor on a couple of VMs, then move to Kubernetes or autoscaling when queue times become a problem.
Q5How do you avoid duplicated pipeline code across projects?
GitLab offers several levels. Inside one file, extends: and YAML anchors reuse job definitions, and hidden jobs (names starting with a dot) act as templates. Across projects, include: pulls in YAML from another project, a remote URL or GitLab's own templates. The modern option is CI/CD components: versioned, reusable pipeline pieces published to the CI/CD Catalog, with spec: inputs that are typed and validated when the pipeline is created. A service then writes a few lines to include the component at a version and pass inputs. Pin versions so a change to the shared component does not break every project at once.
Senior-level questions
S1How would you design a GitLab CI platform for 300 projects with compliance requirements?
I would provide a small catalogue of CI/CD components (build, test, scan, deploy) maintained by a platform team, versioned with semantic tags and documented. Compliance-critical jobs such as SAST, dependency scanning and secret detection would be enforced centrally with pipeline execution policies or compliance frameworks, so projects cannot remove them. Runner fleets are split by trust: autoscaled shared runners for builds, protected runners for deploys, isolated runners inside production networks. Secrets come from Vault or the cloud through OIDC id_tokens, with claims bound to project path and protected refs. Environments are protected with approvals. The trade-off is autonomy: teams must be able to extend the standard pipeline with their own jobs, otherwise they fork it and the platform loses control.
S2Push-based deploys from GitLab CI or pull-based GitOps with Argo CD?
Push-based means a CI job holds cluster credentials and runs kubectl or helm. It is simple, gives immediate feedback in the pipeline and works for servers as well as clusters. The downsides: CI needs powerful credentials, and drift between Git and the cluster goes unnoticed. Pull-based GitOps runs an agent inside the cluster that watches a configuration repository and applies changes. The cluster's credentials never leave it, drift is detected and corrected, and Git history is the audit trail. The costs are another system to run and an indirect feedback loop: CI only updates an image tag in Git. For Kubernetes at scale I lean towards GitOps with CI building and testing images; for a few VMs, push-based from a protected runner is perfectly reasonable.
S3How do you make pipelines both fast and reliable as a project grows?
Speed comes from doing less and doing it in parallel: needs: graphs, rules: changes: to skip unaffected work, test sharding with parallel:, dependency and Docker layer caching, and pre-built CI images that already contain toolchains. Reliability comes from determinism: pinned image versions, lockfiles, no reliance on external services in unit tests, and retries only for known transient infrastructure errors via retry: when:, never for failing tests. Flaky tests are tracked and quarantined with ownership. Measure with pipeline analytics and runner metrics, and set a budget, for example merge request pipelines under ten minutes at the median. The trade-off is maintenance: aggressive skipping can miss cross-service breakage, so pair it with full scheduled pipelines on the default branch.
Glossary
- Pipeline
- One run of all jobs triggered by a push, merge request, tag, schedule or API call.
- Stage
- A group of jobs that run in parallel; stages run in the order listed.
- Job
- A named unit of work with a script, run by a runner in a fresh environment.
- Runner
- An agent that picks jobs from GitLab and runs them, using a chosen executor.
- Executor
- How a runner runs jobs: shell, docker, kubernetes, autoscaled VMs and others.
- Artifact
- Files a job saves for later jobs in the pipeline or for download.
- Protected variable
- A CI/CD variable only injected into pipelines on protected branches or tags.
- Environment
- A named deploy target that GitLab tracks, with history, approvals and rollback.
- CI/CD component
- A versioned, reusable piece of pipeline config with typed inputs, published to the CI/CD Catalog.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.