Your first pipeline in GitLab CI
We build a simple .gitlab-ci.yml from scratch: hello world, stages, artifacts, a SHA tag.
We build a simple .gitlab-ci.yml from scratch: hello world, stages, artifacts,
rules and tagging an image by commit.
Why
In GitLab CI the whole pipeline is described in one file, .gitlab-ci.yml, at the
repository root. You don't need a separate CI server: CI is built into GitLab, and the
commands run on a runner, meaning a machine or container running the GitLab Runner
program.
Drop the file in the repository, push. GitLab finds it on its own and starts the pipeline. The config lives next to the code and goes through review with it.
An important detail: a pipeline runs for a commit. What's not committed and not pushed, GitLab doesn't see.
Concepts
- Job — a set of commands under the
scriptkey that the runner runs as a whole. If any command fails (non-zero exit code), the job fails. - Stage — a group of jobs. Stages run in order, jobs inside one stage can run in parallel.
- Artifact — files a job saves after itself so later jobs can pick them up.
- Predefined variables —
CI_*variables that GitLab sets for every job on its own: the branch name, the commit hash, the registry address and others. - rules — conditions that decide whether a job enters the pipeline.
How it works
Hello world
The smallest working pipeline:
hello:
script:
- echo "Hello, CI!"
One job named hello, one command inside it. On every push the runner takes a
clean environment (with the Docker executor, a new container), runs echo and
shows the result in the job's log. That's already a full pipeline.
Stages and order
Usually there's more than one job, and some of them need to run in order. That's what stages are for:
stages:
- build
- test
build-job:
stage: build
script:
- echo "Building the app"
- echo "v1" > app.txt
test-job:
stage: test
script:
- echo "Checking"
- test -f app.txt # toy example: checking the file exists, not a unit test
Stages run in sequence: first all of build, then all of test. If a stage has
several jobs, they run in parallel (as long as there are enough free runners). If
build fails, test doesn't even start.
There's a trap in this example: test-job will fail. The file app.txt stayed in
build-job's environment, and test-job started in its own, clean one. Artifacts
fix this.
Passing a file between stages: artifacts
Every job starts in a clean environment, so a file from build won't land in
test on its own. To pass it, declare an artifact:
build-job:
stage: build
script:
- echo "v1" > app.txt
artifacts:
paths:
- app.txt
test-job:
stage: test
script:
- cat app.txt
GitLab saves app.txt after build-job. By default a job gets the artifacts of
all jobs from earlier stages, so test-job finds the file right there. This is
how a build result reaches tests and the deploy.
Running only where needed: rules
You don't always need to run the whole pipeline on every branch. Deploying to the
server definitely should only happen from the main branch. rules restrict when a
job runs:
build-job:
stage: build
script:
- echo "Building"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
CI_COMMIT_BRANCH (the commit's branch) and CI_DEFAULT_BRANCH (the project's
main branch, usually main) are predefined variables, GitLab sets them itself.
Rules are checked in order when the pipeline is created, before any job runs. The
first match decides the job's fate; if without when means "add the job". If no
rule matches, the job isn't in the pipeline at all: it's not visible in the UI,
it's not "skipped", it's "not created".
CI_COMMIT_BRANCH only exists in pipelines for branches. It's absent in pipelines
for merge requests and for tags, and the rule above won't fire there.
Building an image and tagging by SHA
Once you get to a Docker image, the tag matters. A common antipattern: tag
everything as :latest.
# don't do this
- docker build -t myapp:latest .
- docker push myapp:latest
latest is a moving tag: today it points at one image, tomorrow at another. You
can't tell what's running in prod from it, and a rollback turns into guesswork:
there's no old image under a recognizable name anymore. Tag by commit instead:
build-image:
stage: build
image: docker:27.3.1-cli
services:
- docker:27.3.1-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
CI_COMMIT_SHORT_SHA is the first eight characters of the commit hash. A tag like
registry.gitlab.com/group/app:1a2b3c4d is tied firmly to the commit: you can
always tell what the image was built from, and a rollback comes down to deploying
the previous tag. CI_REGISTRY_IMAGE is the address of the project's image in
GitLab's built-in registry.
The docker: image versions are pinned too. GitLab's documentation directly
recommends against using docker:latest: otherwise a new release can break the
job on its own.
Two things in this snippet often get lost in tutorials.
DOCKER_TLS_CERTDIR: "/certs". The dind service (Docker-in-Docker, a separate
Docker daemon inside the job), starting with Docker 19.03, only accepts
connections over TLS by default. The variable says which folder the certificates
live in, and the runner has to share the /certs/client subfolder with both the
job and the service (in the runner's settings, volumes = ["/certs/client", ...]).
If the client and the daemon don't agree on certificates, the first docker
command fails with a connection error to the daemon.
docker login inside the job itself. A login done in a neighboring job doesn't
carry over: every job has its own clean environment with its own
~/.docker/config.json. Without the login, docker build goes through, but
docker push fails with an authorization error.
What's wrong with dind
The docker:dind service brings up a full daemon inside the job, and for that
the runner has to run in privileged mode. GitLab's documentation warns directly:
privileged effectively disables the container's isolation and opens a path to
escaping to the host. On your own runners that's a deliberate tradeoff; on shared
or someone else's runners, it's better to avoid it.
You can build an image without a privileged daemon this way:
- BuildKit in rootless mode or
docker buildxwith an external builder: the build moves to a separate BuildKit instance, the job itself doesn't need a daemon. - Buildah — a builder from the Podman ecosystem, works without a daemon and without root.
- kaniko used to be a popular choice for Kubernetes runners, but in June 2025 Google archived the repository, development there has stopped. Better not to pick it for new projects.
It's easier to start with dind: the config is shorter, the behavior matches local. Switching away makes sense once a privileged runner becomes a problem.
Secrets don't belong in the repository
Don't write the registry password, tokens and keys into .gitlab-ci.yml: the file
lives in git and is visible to everyone with repository access. Secrets live in
Settings > CI/CD > Variables. A variable there has two checkboxes:
- Masked: the value is replaced with
[MASKED]in job logs. Requirements for the value: at least 8 characters, a single line with no spaces. - Protected: the variable is only available to pipelines on protected branches and tags.
In the pipeline you reference a secret by name:
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
The password goes through --password-stdin, not as a command argument, so it
doesn't show up in the output or in the process list.
For the built-in registry you don't need your own variables: GitLab sets
CI_REGISTRY_USER and CI_REGISTRY_PASSWORD itself, if the registry is enabled
for the project. The password here is the job's token and lives as long as the job
runs. Your own REGISTRY_USER/REGISTRY_PASSWORD are only needed for an external
registry (Docker Hub, a private one).
Common mistakes
- A file from the previous stage "disappeared". A job starts in a clean
environment. You need
artifacts:pathson the job that created the file. - There's a fix, but the pipeline doesn't see it. The pipeline takes the commit. Didn't commit or didn't push, so as far as GitLab is concerned there's no fix.
- A deploy went out from a feature branch. The deploy job has no
rules, so it enters the pipeline for any branch. - An image tagged
:latest. A week later there's no telling what's in prod, and nothing to roll back to. Tag by$CI_COMMIT_SHORT_SHA. - The tag was fixed in one place. The publish job puts the image under a new tag, but the deploy job still asks for the old one. It's convenient to keep the tag in a single variable for the whole pipeline.
docker pushfails with an authorization error.docker loginwas done in a different job, or not done at all.- A password in
.gitlab-ci.yml. It's already in the git history, even if the file gets fixed later. The secret has to be reissued and set up as a masked variable.
What you'll need for the tasks
Both labs run on your laptop, no GitLab account needed. The lab runner
run-pipeline.sh plays the role of GitLab and the runner: it takes the latest
commit of the work/ repository, reads its .gitlab-ci.yml and runs the jobs in
clean alpine:3.20.3 containers. It understands stages, variables, image,
before_script, script, artifacts:paths and rules with if. It sets the
variables CI_COMMIT_SHA, CI_COMMIT_SHORT_SHA, CI_COMMIT_BRANCH and
CI_DEFAULT_BRANCH (always main) itself, like GitLab does. It doesn't bring up
services like dind: we don't turn on privileged mode on a laptop.
- Lab 1: write a pipeline with three jobs (build, test, deploy) and restrict the deploy to the main branch. You'll need the "Stages and order", "artifacts" and "rules" sections.
- Lab 2: fix a pipeline that publishes every release under the same tag. The "Building an image and tagging by SHA" section.
- Commands:
git -C work add,git -C work commit,git -C work switch,git -C work log --oneline, any text editor.
A short quiz comes next, and the CI/CD track is done.
Interview-style questions
Interview questions on this topic: 3
Included with practice access, $49 one-time. Get access
Answer out loud or in writing, then reveal the answer and honestly mark whether you knew it.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson cicd-basics-06-gitlab-ci-live
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress