GitLab CI/CD with Docker: A Practical Guide

Blog / Server Management Β· January 7, 2021 Β· Updated June 10, 2026 Β· 10 min read
GitLab CI/CD with Docker: A Practical Guide

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 its script:, 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 test

GitLab 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:rules at 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.
  • rules on a job decide whether that job is added to the pipeline, and can set attributes like when: manual or allow_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.sh

Building 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 so package starts the moment test passes, instead of waiting for the whole stage.
  • Caching keyed by requirements.txt means 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_SHA image that CI just built. On the server, a compose.yaml references image: ${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 ENV or build arg can be extracted from image layers. Inject secrets at runtime (Compose env files, Kubernetes secrets, or BuildKit --secret mounts) 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_TOKEN or 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-slim or a digest rather than latest so 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 dind runners 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.

Share this article