Skip to content
DevOps devops cicd 5 min read

GitLab CI/CD

GitLab CI/CD is the automation engine built directly into GitLab. CI/CD stands for Continuous Integration and Continuous Delivery (automatically building, testing, and shipping your code every time you push). The big selling point is that it is all in one: your Git repository, your issue tracker, your container registry, and your pipelines all live in the same place, so you do not need to wire together a separate tool. Everything is driven by a single file in your project called .gitlab-ci.yml.

How GitLab CI works

When you push a commit, GitLab looks for a file named .gitlab-ci.yml at the root of your repository. This YAML (a simple text format for describing configuration) file tells GitLab what to do. GitLab then hands the work to a runner (a program installed on a server that actually executes your commands). The whole run is called a pipeline.

Three terms you must know:

TermPlain meaning
PipelineThe entire automated run triggered by a push.
StageA named phase of the pipeline, like build, test, or deploy. Stages run one after another.
JobA single unit of work (a set of shell commands). Jobs inside the same stage run in parallel.

So a pipeline contains stages, and each stage contains one or more jobs.

A minimal .gitlab-ci.yml

Here is the smallest useful pipeline. It has one stage and one job that prints a message.

stages:
  - build

build-job:
  stage: build
  script:
    - echo "Building the app..."
    - echo "Done."

Commit this file, push it, and open your project in GitLab under Build > Pipelines. You will see the job run and its log output.

Output:

Running with gitlab-runner 17.1.0
Preparing the "docker" executor
Executing "step_script" stage of the job script
$ echo "Building the app..."
Building the app...
$ echo "Done."
Done.
Job succeeded

Setting up a runner on Ubuntu

GitLab.com gives you free shared runners, so for hosted projects you usually need to do nothing. But if you self-host GitLab or want a dedicated machine (for example, a server that has access to your production network), you install your own runner on Ubuntu.

# Add the official GitLab Runner apt repository
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash

# Install the runner package
sudo apt update
sudo apt install -y gitlab-runner

# Register the runner against your project (grab the token from Settings > CI/CD > Runners)
sudo gitlab-runner register \
  --url https://gitlab.com/ \
  --token glrt-XXXXXXXXXXXXXXXXXXXX \
  --executor docker \
  --docker-image alpine:3.20

Output:

Runtime platform                  arch=amd64 os=linux
Registering runner... succeeded
Runner registered successfully. Feel free to start it, but if it's
running already the config should be automatically reloaded!

The runner installs as a systemd service (the standard Ubuntu service manager), so it starts on boot. Check it like any other service:

sudo systemctl status gitlab-runner

When to use a self-hosted runner: Use one when your jobs need access to a private network, special hardware, or large caches. Stick with GitLab’s shared runners for simple public or hosted projects — they are free and need zero maintenance.

A real build-test-deploy pipeline

This is the pattern you will use most: three stages that run in order. If the build fails, the test stage never starts; if tests fail, deploy never runs. This is the whole point of CI/CD — broken code stops before it reaches production.

stages:
  - build
  - test
  - deploy

# Cache downloaded dependencies between runs to save time
default:
  image: node:20
  cache:
    paths:
      - node_modules/

build:
  stage: build
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

test:
  stage: test
  script:
    - npm ci
    - npm test

deploy:
  stage: deploy
  script:
    - apt-get update && apt-get install -y rsync openssh-client
    - rsync -avz --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:/var/www/app/"
  environment:
    name: production
  rules:
    # Only deploy when pushing to the main branch
    - if: $CI_COMMIT_BRANCH == "main"

A few things to notice:

  • artifacts are files a job produces (here the compiled dist/ folder) that GitLab saves and passes to later stages. Without artifacts, the deploy job would have no built files to ship.
  • cache keeps node_modules/ between pipeline runs so npm ci is faster. The difference: artifacts move forward between stages in one pipeline; cache is reused across pipelines.
  • rules control when a job runs. Here, deploy only happens on the main branch.
  • $DEPLOY_USER and $DEPLOY_HOST are CI/CD variables set under Settings > CI/CD > Variables so secrets never sit in the YAML.

Artifacts vs cache — when to use which

FeaturePurposeWhen to use
ArtifactsPass build outputs to later stages and let humans download them.Compiled binaries, test reports, the dist/ folder.
CacheSpeed up future runs by reusing files.Dependency folders like node_modules/, ~/.m2, vendor/.

Gotcha: Never rely on cache to deliver files to a later stage. Cache is a performance hint and GitLab may skip restoring it. Use artifacts when a file must be present downstream.

GitLab CI vs GitHub Actions

Both tools do the same job — run pipelines from a YAML file in your repo. The differences are mostly about ecosystem and structure.

AspectGitLab CI/CDGitHub Actions
Config file.gitlab-ci.yml (one file).github/workflows/*.yml (many files)
Core building blockStages and jobsWorkflows, jobs, and steps
Reusable unitsinclude and templatesMarketplace “actions”
Built-in registryContainer + package registry includedSeparate GitHub Container Registry
Best fitTeams that want one all-in-one platformProjects already living on GitHub

If your code already lives on GitHub, Actions is the natural choice. If you want issues, registry, and pipelines under one roof — or you self-host — GitLab CI is the stronger fit.

Best practices

  • Keep secrets in Settings > CI/CD > Variables, never in .gitlab-ci.yml; mark them Masked and Protected.
  • Use rules (not the older only/except) to control when jobs run — it is the modern, more flexible syntax.
  • Cache dependency directories to keep pipelines fast, and set expire_in on artifacts so storage does not fill up.
  • Pin your runner images to a specific tag (node:20, not node:latest) so builds stay reproducible.
  • Gate deploy jobs behind a branch check so only main (or a release branch) can ship to production.
  • Start with one shared runner; add self-hosted runners only when you hit a real need like private-network access.
Last updated June 15, 2026
Was this helpful?