Continuous integration and continuous delivery (CI/CD) is how modern teams ship software safely and often. GitLab gives you Git hosting, a CI/CD engine, and a built-in container registry in one place, and Docker gives you reproducible build and runtime environments. Put them together and every push can be built, tested, packaged as an image, and deployed without anyone running commands by hand.
This guide is a practical, current (2026) walk-through of CI/CD with GitLab CI/CD and Docker. It covers the .gitlab-ci.yml pipeline model, GitLab Runners and executors, building images with the modern Docker-in-Docker (docker:dind) or rootless Kaniko/Buildah builders, a realistic build β test β package β deploy pipeline, and the security practices that keep secrets out of your images.
CI vs CD vs Continuous Deployment
These three terms are used loosely, so it is worth being precise:
- Continuous Integration (CI): every change is merged frequently into the main branch and automatically built and tested. The goal is to catch integration problems within minutes of a commit, not weeks later.
- Continuous Delivery (CD): every change that passes CI is automatically packaged into a release-ready artifact (here, a Docker image) and can be deployed to production at the push of a button. A human still approves the final release.
- Continuous Deployment: the same as continuous delivery, but the final step is automatic too. Every green pipeline goes straight to production with no manual gate.
GitLab models all three with the same .gitlab-ci.yml. The only difference is whether your deploy job is a manual gate (when: manual) for delivery, or runs automatically for deployment.
How a GitLab pipeline works
You define your pipeline in a .gitlab-ci.yml file at the root of the repository. GitLab reads it on every push and creates a pipeline made of stages, and each stage contains one or more jobs.
- Stages run in order. By default a stage starts only after every job in the previous stage succeeds.
- Jobs within the same stage run in parallel (subject to runner capacity).
- Each job runs in a clean environment defined by an
image:, executes the shell commands in itsscript:, and can pass files forward as artifacts or speed up repeat runs with a cache. needs:lets you build a directed acyclic graph (DAG) so a job can start as soon as the specific jobs it depends on finish, instead of waiting for the whole previous stage.
Here is a minimal pipeline with three stages:
# .gitlab-ci.yml
stages:
- build
- test
- deploy
build-app:
stage: build
image: node:22-alpine
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
unit-tests:
stage: test
image: node:22-alpine
script:
- npm ci
- npm testGitLab Runners and executors
Jobs do not run inside GitLab itself, they run on GitLab Runners. A runner is an agent you register against a project, group, or the whole instance:
- Shared runners are provided by the GitLab instance (and are the default on GitLab.com).
- Group runners are shared across all projects in a group.
- Project (specific) runners are dedicated to one project, which is what you want for production deploy jobs that need access to a particular server.
Each runner uses an executor that decides where the script: actually runs:
- docker executor: each job runs in a fresh container from the job's
image:. This is the most common choice and what the examples below assume. - shell executor: commands run directly on the runner host. Simple, but the host needs every tool pre-installed and jobs are not isolated.
- kubernetes executor: each job becomes a pod in a cluster, which scales well for busy teams.
Use tags to route a job to the right runner. A job with tags: [production] will only be picked up by a runner registered with that tag, which is how you keep deploys on the correct server. If you self-host GitLab and its runners, see our walkthrough on automated Django testing with self-hosted GitLab CI and Docker.
Controlling when jobs run: workflow and rules
Older GitLab pipelines used only: / except: to decide when a job ran. That syntax is legacy, use rules: and workflow: instead. They are more expressive and compose cleanly with CI/CD variables.
workflow:rulesat the top of the file decide whether a pipeline is created at all, which is the cleanest way to avoid duplicate pipelines for merge requests and branches.ruleson a job decide whether that job is added to the pipeline, and can set attributes likewhen: manualorallow_failure.
This example creates pipelines only for merge requests and the default branch, and gates the deploy job behind a manual click on the default branch:
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
deploy-prod:
stage: deploy
rules:
# only on the default branch, and only when a human clicks "play"
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
script:
- ./deploy.shBuilding Docker images in CI
To package your app you need to build a Docker image inside the pipeline and push it to a registry. Every GitLab project ships with a Container Registry at $CI_REGISTRY_IMAGE, and CI exposes ready-made credentials so you never hard-code a password:
$CI_REGISTRY- the registry hostname (e.g.registry.gitlab.com).$CI_REGISTRY_IMAGE- the image path for this project.$CI_REGISTRY_USER/$CI_REGISTRY_PASSWORD- short-lived credentials scoped to the job.$CI_JOB_TOKEN- a per-job token that can also authenticate to the registry and other GitLab APIs.
Tag images with an immutable identifier such as $CI_COMMIT_SHA so every build is traceable, and optionally move a latest tag for convenience. (Hosting your registry on your own domain? See setting up the GitLab Container Registry on your own domain.)
There are two modern ways to build images in CI.
Option 1: Docker-in-Docker (dind)
This is the classic approach: run the Docker CLI in your job and attach the docker:dind service to provide a Docker daemon. Enable BuildKit (DOCKER_BUILDKIT: 1) so you can use docker buildx features and better caching. The job needs a privileged runner, which is the main trade-off.
build-image:
stage: build
image: docker:27-cli
services:
- docker:27-dind
variables:
DOCKER_BUILDKIT: "1"
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_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
- docker push "$CI_REGISTRY_IMAGE:latest"Option 2: Rootless builds with Kaniko or Buildah
If you cannot (or do not want to) run a privileged dind daemon, build images rootless with Kaniko or Buildah. They build straight from a Dockerfile inside an unprivileged container, which is safer on shared and Kubernetes runners. Kaniko reads the standard $CI_REGISTRY* variables through an auth config:
build-image-kaniko:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- |
echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
- >-
/kaniko/executor
--context "$CI_PROJECT_DIR"
--dockerfile "$CI_PROJECT_DIR/Dockerfile"
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
--destination "$CI_REGISTRY_IMAGE:latest"A realistic pipeline: build, test, package, deploy
Putting it together, here is a complete .gitlab-ci.yml for a containerized web app. It builds and tests the code, builds and pushes an image with dind, then deploys to a server over SSH using Docker Compose v2 (the docker compose plugin, not the deprecated docker-compose binary). The deploy is a manual gate on the default branch, so this is continuous delivery, remove when: manual to make it continuous deployment.
stages:
- build
- test
- package
- deploy
variables:
IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
# Cache language dependencies between runs (keyed by lockfile)
.python-cache: &python-cache
cache:
key:
files:
- requirements.txt
paths:
- .pip-cache/
build:
stage: build
image: python:3.12-slim
<<: *python-cache
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
script:
- pip install -r requirements.txt
- python -m compileall .
test:
stage: test
image: python:3.12-slim
<<: *python-cache
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
script:
- pip install -r requirements.txt
- pytest --maxfail=1 --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml
package:
stage: package
image: docker:27-cli
services:
- docker:27-dind
variables:
DOCKER_BUILDKIT: "1"
DOCKER_TLS_CERTDIR: "/certs"
needs: ["test"]
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker build -t "$IMAGE_TAG" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push "$IMAGE_TAG"
- docker push "$CI_REGISTRY_IMAGE:latest"
deploy-prod:
stage: deploy
image: docker:27-cli
tags:
- production
environment:
name: production
url: https://app.example.com
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
before_script:
- apk add --no-cache openssh-client
- eval "$(ssh-agent -s)"
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- |
ssh "$DEPLOY_USER@$DEPLOY_HOST" "
echo '$CI_REGISTRY_PASSWORD' | docker login -u '$CI_REGISTRY_USER' --password-stdin '$CI_REGISTRY' &&
cd /opt/app &&
export IMAGE_TAG='$IMAGE_TAG' &&
docker compose pull &&
docker compose up -d --remove-orphans &&
docker image prune -f
"A few things worth calling out in that pipeline:
needs: ["test"]turns the pipeline into a DAG sopackagestarts the momenttestpasses, instead of waiting for the whole stage.- Caching keyed by
requirements.txtmeans dependency installs are fast on repeat runs, while artifacts carry the JUnit test report into GitLab's UI. - The deploy job's server runs
docker compose pull && docker compose up -d, pulling the exact$CI_COMMIT_SHAimage that CI just built. On the server, acompose.yamlreferencesimage: ${IMAGE_TAG}so deploys are atomic and rollbacks are just redeploying an older SHA. - For multi-node setups you might deploy to a Swarm or Kubernetes cluster instead of a single host, see clustering Docker containers with Docker Swarm, and our guide to deploying a Django project into a Docker container for app-side packaging.
Environments and manual CD gates
The environment: keyword tells GitLab that a job deploys to a named target such as staging or production. GitLab then tracks deployment history, shows the live URL, and enables one-click rollback and re-deploy from the Environments page.
Combine environment: with when: manual to get a true continuous-delivery gate: the pipeline runs all the way to a ready-to-ship image automatically, and a human clicks play to release. You can also require an approval or restrict who can run the job using protected environments, so only maintainers can deploy to production.
Security best practices
CI runners handle your source code and your deploy credentials, so treat the pipeline as production infrastructure:
- Never bake secrets into images. Anything in a Dockerfile
ENVor build arg can be extracted from image layers. Inject secrets at runtime (Compose env files, Kubernetes secrets, or BuildKit--secretmounts) instead. - Use protected and masked variables. Store credentials as CI/CD variables, mark them masked so they are hidden in job logs, and protected so they are only exposed on protected branches and tags, never on a fork's merge request.
- Prefer least-privilege tokens. Use
$CI_JOB_TOKENor a narrowly scoped deploy token instead of a personal access token, and give registry/deploy credentials only the permissions they actually need. - Pin image versions. Reference
python:3.12-slimor a digest rather thanlatestso a surprise upstream change cannot break or compromise a build. - Scan your images. GitLab's container scanning and a
docker scout/Trivy step catch known CVEs before an image reaches production. - Lock down runners. Privileged
dindrunners are powerful, isolate them, avoid running them on the same host as other workloads, or switch to rootless Kaniko/Buildah where you can.
Wrapping up
With GitLab CI/CD and Docker you get a single, version-controlled pipeline that builds, tests, packages, and deploys every change, fast feedback for developers and a repeatable, auditable path to production. Start small with a build-and-test pipeline, add image building once tests are green, then layer in environments and a manual deploy gate when you are ready to ship continuously.
If you would like help designing resilient pipelines or hardening your container deployments, MicroPyramid offers server maintenance and DevOps services. Reach us at hello@micropyramid.com.
Frequently Asked Questions
What is the difference between continuous delivery and continuous deployment?
Both automatically build and package every change that passes CI. Continuous delivery stops at a release-ready artifact and waits for a human to approve the final production release (a when: manual job in GitLab). Continuous deployment removes that gate, so every green pipeline goes straight to production automatically.
Should I use Docker-in-Docker or Kaniko to build images in GitLab CI?
Use Docker-in-Docker (docker:dind) when you control the runner and want full Docker/BuildKit features, it is the most flexible but needs a privileged runner. Use Kaniko or Buildah when you want rootless, unprivileged builds, which is safer on shared or Kubernetes runners where you should not grant privileged access.
How do I push images to the GitLab Container Registry without storing a password?
GitLab injects credentials into every job: log in with docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" using $CI_REGISTRY_PASSWORD, or authenticate with $CI_JOB_TOKEN. These are short-lived and scoped to the job, so you never hard-code a long-lived secret.
Why should I replace only/except with rules and workflow?
only / except is legacy and harder to compose. rules: gives per-job conditions with full access to CI/CD variables and lets you set attributes like when: manual and allow_failure, while workflow:rules controls whether a pipeline is created at all, which is the clean way to avoid duplicate branch/merge-request pipelines.
What is the best way to keep secrets out of Docker images and pipelines?
Never put secrets in a Dockerfile ENV or build arg, they remain readable in image layers. Inject them at runtime via Compose env files, Kubernetes secrets, or BuildKit --secret mounts. In GitLab, store credentials as masked and protected CI/CD variables and prefer least-privilege deploy tokens over personal access tokens.
How do I speed up slow GitLab pipelines?
Cache dependencies with a cache: keyed on your lockfile, use needs: to run jobs as a DAG instead of waiting for whole stages, enable BuildKit layer caching for image builds, pin and reuse small base images, and run independent jobs in parallel. Together these often cut pipeline times by more than half.