Practical GitLab CI/CD recipes: pipeline structure, Docker-in-Docker builds, caching, artifacts, and a full Docker build-and-push workflow.
GitLab CI/CD is one of the most complete CI platforms available today — the pipeline definition lives in the same repository as the code, the built-in container registry removes the need for a separate Docker Hub account, and the services keyword makes ephemeral sidecar containers (like a Docker daemon or a test database) trivially easy. These notes cover the patterns that come up on almost every real project.
Every pipeline is defined in a .gitlab-ci.yml at the root of the repository. The top-level keys you’ll use on every project:
# .gitlab-ci.yml — minimal working examplestages: # ordered list of stage names - build - test - deployvariables: # pipeline-wide environment variables IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHAdefault: # defaults applied to all jobs unless overridden image: alpine:3.19 tags: - docker # route jobs to runners with the "docker" tag# A job belongs to a stage and runs its `script` steps sequentiallybuild:app: stage: build script: - echo "Building $IMAGE_NAME"test:unit: stage: test script: - echo "Running unit tests" allow_failure: false # default — fail the pipeline if this job failsdeploy:staging: stage: deploy script: - echo "Deploying to staging" environment: name: staging rules: - if: $CI_COMMIT_BRANCH == "main"
Stage order matters — jobs in stage n+1 only start after all jobs in stage n have passed. Jobs within the same stage run in parallel by default.
Running docker build inside a GitLab CI job requires a Docker daemon. The recommended approach is to use the official docker:dindservice — GitLab spins it up as a sidecar container that the job container connects to over TLS.
DinD requires the runner to be configured with the docker executor (or Kubernetes executor with the right settings). It does not work on shell executors by default.
build:image: stage: build image: docker:cli # only the CLI — no daemon services: - docker:dind # ephemeral Docker daemon as a sidecar variables: DOCKER_HOST: tcp://docker:2376 DOCKER_TLS_CERTDIR: "/certs" # GitLab mounts certs here automatically before_script: - docker info # verify the daemon is reachable script: - docker build -t my-image:latest . - docker run --rm my-image:latest echo "smoke test passed"
Use docker:cli (not docker:latest) as the job image. It ships only the client binary, keeping the image small. The daemon lives exclusively in the docker:dind service container.
GitLab’s services keyword is documented at https://docs.gitlab.com/ci/services/. The services feature isn’t Docker-specific — you can use it to spin up PostgreSQL, Redis, or any other daemon your tests need.
This is a production-ready template for building a Docker image and pushing it to the built-in GitLab Container Registry on every push to main, plus every MR:
These two features are often confused. The distinction is important:
cache
artifacts
cache + artifacts together
Cache speeds up jobs by persisting files between pipeline runs on the same runner. Use it for dependency directories (node_modules, .venv, Maven’s ~/.m2).
build:node: stage: build image: node:20-alpine cache: key: files: - package-lock.json # cache busts when lockfile changes paths: - node_modules/ policy: pull-push # pull at job start, push at job end script: - npm ci - npm run build
Cache is not guaranteed to be available — it’s a best-effort optimisation. Never rely on cache for correctness, only for speed.
Artifacts are files explicitly passed between jobs within the same pipeline. They’re stored on the GitLab server and are always reliable.
build:app: stage: build script: - make build artifacts: paths: - dist/ # available to all downstream jobs expire_in: 1 week # cleaned up automaticallytest:integration: stage: test needs: [build:app] # download artifacts from this specific job script: - ls dist/ # files are here - ./run-tests.sh dist/
A common pattern: cache dependencies, artifact the build output.
Prefer rules over the legacy only/except keywords — it’s more expressive and evaluates top-to-bottom:
deploy:production: stage: deploy script: ./deploy.sh prod rules: - if: $CI_COMMIT_TAG # tagged releases only when: manual # requires a human click - if: $CI_COMMIT_BRANCH == "main" when: on_success # auto-deploy on main - when: never # skip everything else
Use needs to create a Directed Acyclic Graph — jobs start as soon as their dependencies finish, rather than waiting for the entire previous stage:
test:unit: stage: test needs: [build:app] # starts as soon as build:app passestest:e2e: stage: test needs: [build:app] # also starts immediately — parallel with test:unitdeploy:staging: stage: deploy needs: - test:unit - test:e2e # waits for both tests, not the whole test stage