GitLab CI/CD can build and push Docker images, but it’s not just about running docker build and docker push; it’s about orchestrating that process securely and efficiently within your pipeline.

Here’s a typical GitLab CI/CD setup for building and pushing Docker images.

stages:
  - build
  - push

variables:
  # Use the project's registry, automatically tagged with the commit SHA
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  # A "latest" tag is also useful
  IMAGE_TAG_LATEST: $CI_REGISTRY_IMAGE:latest

docker_build:
  stage: build
  image: docker:20.10.16 # Use a specific Docker version
  services:
    - docker:20.10.16-dind # Docker-in-Docker service
  script:
    - echo "Building Docker image..."
    - docker build -t $IMAGE_TAG .
    - docker build -t $IMAGE_TAG_LATEST . # Build for both tags simultaneously
  # Need to cache Docker layers to speed up subsequent builds
  cache:
    key: docker-cache
    paths:
      - /var/lib/docker

docker_push:
  stage: push
  image: docker:20.10.16
  services:
    - docker:20.10.16-dind
  script:
    - echo "Logging into GitLab Container Registry..."
    # CI_REGISTRY_USER and CI_REGISTRY_PASSWORD are pre-defined CI/CD variables
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
    - echo "Pushing Docker image..."
    - docker push $IMAGE_TAG
    - docker push $IMAGE_TAG_LATEST
  # Only push if the branch is 'main' or if it's a tag
  only:
    - main
    - tags
  # Ensure the push job only runs after the build job succeeds
  needs:
    - docker_build

This pipeline defines two stages: build and push. The docker_build job uses the docker:dind (Docker-in-Docker) service to run Docker commands. It builds the image twice, tagging it with both the commit SHA and latest. The cache directive helps speed up builds by preserving Docker layers between pipeline runs.

The docker_push job also uses docker:dind. It first logs into the GitLab Container Registry using predefined CI/CD variables (CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, CI_REGISTRY). Then, it pushes both tags of the image. The only clause restricts this job to run only on the main branch or when a Git tag is created, preventing accidental pushes from feature branches. The needs keyword ensures that the push stage only executes if the build stage was successful.

The most surprising true thing about this setup is that the docker:dind service is effectively running a separate, nested Docker daemon, and your build commands are interacting with that daemon, not the host’s. This isolation is key for security and reproducibility.

Let’s look at how this works with a real-world example. Imagine you have a simple Dockerfile in your project root:

FROM alpine:latest
RUN apk add --no-cache nginx
CMD ["nginx", "-g", "daemon off;"]

When the docker_build job runs, the docker build -t $IMAGE_TAG . command executes within the docker:20.10.16-dind container. The . tells Docker to look for the Dockerfile in the current directory, which is the checked-out Git repository. The build context (your project files) is sent to the dind daemon.

The cache section is crucial. When docker build runs, it looks for existing layers in its cache. If a layer’s instruction and context haven’t changed, it reuses the cached layer, dramatically speeding up builds. The paths: - /var/lib/docker tells GitLab CI to cache the entire Docker daemon’s data directory. This means subsequent builds can leverage previously built layers, even across different pipeline runs.

The docker_push job’s login command is a common point of confusion. echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY is the standard, secure way to authenticate. GitLab provides these variables automatically for projects hosted on GitLab.com or GitLab self-managed instances configured with a registry. The --password-stdin flag ensures the password isn’t exposed in process lists.

The only directive is a powerful way to control when your images are published. For example, only: - main means the docker_push job will only run for pipelines triggered by commits to the main branch. only: - tags means it will run when a Git tag is pushed, which is common for releasing specific versions. You can combine these with except to further refine behavior.

The needs keyword is an optimization and dependency manager. Instead of running all jobs in a stage in parallel or sequentially based on stage order alone, needs allows you to define explicit dependencies. Here, docker_push needs docker_build to complete successfully. If docker_build fails, docker_push will not even be attempted. This saves resources and prevents downstream failures.

The IMAGE_TAG and IMAGE_TAG_LATEST variables are constructed using GitLab’s predefined CI/CD variables. $CI_REGISTRY_IMAGE is the path to your project’s container registry (e.g., registry.gitlab.com/your-group/your-project). $CI_COMMIT_SHORT_SHA is a unique identifier for the current commit, ensuring that each build has a distinct, traceable tag. $CI_REGISTRY_IMAGE:latest is a common convention for the most recent stable build.

This setup is highly configurable. You might want to:

  • Build on specific branches only: Use only: - main or only: - develop on your docker_build job.
  • Use a different base image for CI: You could use image: docker:latest for the job itself, but still rely on docker:20.10.16-dind for the service.
  • Pass build arguments: Add args: ["--build-arg", "MY_VAR=my_value"] to your docker build command.
  • Use multi-stage builds: Your Dockerfile can have multiple FROM statements, allowing you to build artifacts in one stage and copy them to a smaller final image in another. This CI configuration doesn’t need to change for that, as it just runs docker build.

One detail often overlooked is the lifecycle of the dind service. When a job starts, the dind service spins up. When the job finishes, the dind service is stopped. If you’re using the Docker cache, the /var/lib/docker directory is persisted between job runs within the same pipeline. However, it’s not persisted across different pipeline executions unless you explicitly configure a more persistent cache mechanism (like object storage for Docker layer caching).

The next logical step after reliably building and pushing images is to use them. This typically involves deploying that image to a container orchestrator like Kubernetes or Docker Swarm, which would be your next CI/CD stage.

Want structured learning?

Take the full Gitlab course →