Sign in
← CI/CD
12 min · medium · Lesson 6 of 7

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

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:

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:

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

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.

A short quiz comes next, and the CI/CD track is done.

Done reading?

Mark the lesson as read to move your track progress forward.

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.

Get access for $49

Tell the agent: use MCP izi, give me lesson cicd-basics-06-gitlab-ci-live