GitLab CI/CD¶
GitLab bundles source control, CI/CD, and a container registry in one application. The pipeline lives in a single file at the repo root — .gitlab-ci.yml — and runs on runners, which are agents you (or gitlab.com) host.
GitHub Actions vs GitLab CI at a glance
| Concept | GitHub Actions | GitLab CI |
|---|---|---|
| Pipeline file | .github/workflows/*.yml (many) |
.gitlab-ci.yml (one, can include others) |
| Execution unit | job → steps | stage → jobs |
| Ordering | needs: between jobs |
stages: list (+ needs: for DAGs) |
| Reusable units | marketplace actions | include:, templates, extends: |
| Registry | GHCR | built-in per-project registry |
| Agents | hosted or self-hosted runners | shared or self-hosted runners |
How a GitLab Pipeline Runs¶
flowchart LR
C[git push] --> P[Pipeline<br>.gitlab-ci.yml]
P --> S1[Stage: test]
S1 --> S2[Stage: build]
S2 --> S3[Stage: deploy]
S1 -.jobs run in parallel.- J1[lint]
S1 -.- J2[unit-tests]
S3 --> ENV[Environment<br>production]
classDef src fill:#fef3c7,stroke:#d97706,color:#78350f
classDef stage fill:#dbeafe,stroke:#2563eb,color:#172554
classDef job fill:#ede9fe,stroke:#7c3aed,color:#3b0764
classDef prod fill:#dcfce7,stroke:#16a34a,color:#14532d
class C src
class P,S1,S2,S3 stage
class J1,J2 job
class ENV prod
- Jobs in the same stage run in parallel.
- A stage starts only when the previous stage succeeded.
- Each job runs in a fresh container defined by its
image:.
Runner Installation (Ubuntu)¶
On gitlab.com, shared runners work out of the box. For self-hosted GitLab or private infrastructure, install your own:
# 1. Add the official repository and install
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt install gitlab-runner
# 2. Register the runner
# Get the URL and token from: Project → Settings → CI/CD → Runners
sudo gitlab-runner register \
--url https://gitlab.com \
--token <RUNNER_TOKEN> \
--executor docker \
--docker-image alpine:latest
# 3. Verify
sudo gitlab-runner status
sudo gitlab-runner list
Pick the docker executor
The docker executor runs every job in a clean container — the closest match to how gitlab.com shared runners behave, and it keeps the runner host clean.
Anatomy of .gitlab-ci.yml¶
stages: [test, build, deploy] # execution order
default:
image: alpine:latest # image used when a job doesn't set one
variables:
APP_NAME: demo-app
unit-tests:
stage: test
image: python:3.13-slim
script:
- pip install -r requirements-dev.txt
- pytest -v
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
build-job:
stage: build
script:
- echo "building $APP_NAME"
artifacts:
paths: [dist/] # pass files to later stages
expire_in: 1 week
deploy-job:
stage: deploy
script:
- ./deploy.sh
environment:
name: production
url: https://myapp.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual # requires a click — a deploy gate
Key built-in variables you will use constantly:
| Variable | Meaning |
|---|---|
$CI_COMMIT_SHA / $CI_COMMIT_SHORT_SHA |
Commit being built |
$CI_COMMIT_BRANCH |
Branch name |
$CI_REGISTRY |
This GitLab's registry hostname |
$CI_REGISTRY_IMAGE |
registry.gitlab.com/<group>/<project> |
$CI_REGISTRY_USER / $CI_REGISTRY_PASSWORD |
Auto-issued registry credentials |
$CI_PIPELINE_SOURCE |
What triggered the run (push, merge_request_event, …) |
Building Docker Images: Docker-in-Docker¶
Jobs run in containers, so building an image inside a job needs the docker-in-docker (dind) service:
build-image:
stage: build
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE --all-tags
The pushed image appears under the project's Deploy → Container Registry — no external registry account needed.
Full Example: Python Web App¶
The same FastAPI app from the Python pipeline page, expressed in GitLab CI:
stages: [test, build, deploy]
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
lint:
stage: test
image: python:3.13-slim
script:
- pip install ruff
- ruff check .
pytest:
stage: test
image: python:3.13-slim
cache:
key: { files: [requirements-dev.txt] }
paths: [.cache/pip]
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
script:
- pip install -r requirements-dev.txt
- pytest -v --cov=app --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml # test results shown in MR UI
build-image:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $IMAGE_TAG -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE --all-tags
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$DEPLOY_SSH_KEY" | ssh-add -
script:
- ssh -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_HOST "
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY &&
docker pull $CI_REGISTRY_IMAGE:latest &&
docker stop app || true && docker rm app || true &&
docker run -d --name app --restart unless-stopped -p 80:8000 $CI_REGISTRY_IMAGE:latest"
environment:
name: production
url: http://myapp.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
Set DEPLOY_SSH_KEY, DEPLOY_USER, and DEPLOY_HOST under Settings → CI/CD → Variables (mark the key as Masked and Protected).
Full Example: Java Web App¶
The Spring Boot app from the Java pipeline page, in GitLab CI:
stages: [test, build, deploy]
maven-verify:
stage: test
image: maven:3.9-eclipse-temurin-21
cache:
key: maven
paths: [.m2/repository]
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
script:
- mvn --batch-mode verify
artifacts:
when: always
reports:
junit: target/surefire-reports/TEST-*.xml
build-image:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE --all-tags
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-production:
stage: deploy
script:
- echo "Same SSH pattern as the Python example, or hand off to ArgoCD"
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
Best Practices¶
- Use
rules:(not the legacyonly/except) to control when jobs run - Publish JUnit reports with
artifacts.reports.junit— failures render inline in merge requests - Cache dependencies keyed on the lockfile (
cache.key.files) - Make production deploys
when: manualand tie them to anenvironment - Mark credential variables Masked and Protected
- Split long pipelines with
include:once they grow past ~200 lines
For Kubernetes deployments, the GitOps handoff works exactly as with GitHub Actions — the pipeline builds and pushes the image, then ArgoCD syncs the manifests.