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:
| Term | Plain meaning |
|---|---|
| Pipeline | The entire automated run triggered by a push. |
| Stage | A named phase of the pipeline, like build, test, or deploy. Stages run one after another. |
| Job | A 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:
artifactsare files a job produces (here the compileddist/folder) that GitLab saves and passes to later stages. Without artifacts, thedeployjob would have no built files to ship.cachekeepsnode_modules/between pipeline runs sonpm ciis faster. The difference: artifacts move forward between stages in one pipeline; cache is reused across pipelines.rulescontrol when a job runs. Here, deploy only happens on themainbranch.$DEPLOY_USERand$DEPLOY_HOSTare CI/CD variables set under Settings > CI/CD > Variables so secrets never sit in the YAML.
Artifacts vs cache — when to use which
| Feature | Purpose | When to use |
|---|---|---|
| Artifacts | Pass build outputs to later stages and let humans download them. | Compiled binaries, test reports, the dist/ folder. |
| Cache | Speed 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.
| Aspect | GitLab CI/CD | GitHub Actions |
|---|---|---|
| Config file | .gitlab-ci.yml (one file) | .github/workflows/*.yml (many files) |
| Core building block | Stages and jobs | Workflows, jobs, and steps |
| Reusable units | include and templates | Marketplace “actions” |
| Built-in registry | Container + package registry included | Separate GitHub Container Registry |
| Best fit | Teams that want one all-in-one platform | Projects 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 olderonly/except) to control when jobs run — it is the modern, more flexible syntax. - Cache dependency directories to keep pipelines fast, and set
expire_inon artifacts so storage does not fill up. - Pin your runner images to a specific tag (
node:20, notnode: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.