Pushing container images to a registry is a fundamental part of modern CI/CD, but the devil is in the details.

# .gitlab-ci.yml
build_image:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

This pipeline snippet shows a basic docker build and docker push. The docker:latest image provides the Docker CLI, and the docker:dind service (Docker-in-Docker) spins up a separate Docker daemon for the job to use. The CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, and CI_REGISTRY are built-in GitLab CI/CD variables that automatically provide credentials and the registry URL for your GitLab instance. The image is tagged with the short commit SHA, ensuring uniqueness.

The core problem this solves is versioning and distribution of your application’s runtime environment. Instead of shipping source code and hoping the deployment environment has the correct dependencies, you ship a self-contained, immutable container image. This image includes your application code, its dependencies, and the necessary runtime (like Node.js, Python, or a JVM). The CI pipeline is responsible for building this image, testing it, and then publishing it to a registry (like GitLab’s) so that deployment environments can pull and run it.

Internally, docker build reads your Dockerfile, downloads base images, executes the instructions (like RUN, COPY, ADD), and layers the changes into a new image. Each instruction typically creates a new image layer. docker push then uploads these layers to the specified registry. GitLab’s Container Registry is integrated directly into your project, meaning you don’t need to set up a separate registry service. The CI_REGISTRY_IMAGE variable is pre-formatted as gitlab.com/<group>/<project> (or your self-hosted domain), making it convenient.

The real magic is in those predefined CI/CD variables. GitLab automatically injects CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, and CI_REGISTRY into your pipeline environment when you use the built-in registry. This means you don’t have to manually create tokens or manage credentials within your .gitlab-ci.yml file. The CI_REGISTRY_USER is a service account, and CI_REGISTRY_PASSWORD is a project access token automatically generated for this purpose. This secure mechanism ensures your pipeline can authenticate to the registry without exposing sensitive credentials.

One aspect that trips people up is the docker:dind service. If you forget to include it, or if it’s not configured correctly, your docker build commands will fail with errors like "Cannot connect to the Docker daemon." This is because the docker image only contains the client, not the daemon that actually builds images. The dind service provides that daemon. You also need to ensure your runner has the necessary privileges, often requiring privileged = true in the runner’s config.toml if you’re using Docker executor.

A common, though less frequent, issue is related to registry authentication failures. If your GitLab instance uses custom TLS certificates or if you’re on a self-hosted GitLab with an improperly configured registry, you might encounter SSL errors. In such cases, you might need to add DOCKER_TLS_CERTDIR: "/certs" to your job’s environment variables and ensure the necessary certificates are mounted into the docker:dind service. This tells the Docker daemon to use a specific directory for TLS certificates.

Another subtle point is image tagging. While CI_COMMIT_SHORT_SHA is excellent for unique identification, you’ll often want to push images with more human-readable tags for deployments, like latest or version numbers (e.g., v1.2.0). You can achieve this by adding multiple docker tag commands before the docker push, or by using CI_COMMIT_TAG if you’re pushing on a tag event. For example:

    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest

This ensures that latest always points to the most recently built image in the main branch, or a specific version if you’re tagging releases.

The next hurdle is managing image cleanup. By default, GitLab’s registry will keep all pushed images indefinitely. This can lead to significant storage costs. You’ll eventually need to configure a cleanup policy, either through GitLab’s UI (Project Settings > Container Registry > Cleanup Policy) or via API, to automatically prune old or unused images based on criteria like age or number of images.

Want structured learning?

Take the full Gitlab-ci course →