GitLab’s continuous integration and deployment are great. If you have special runners, you can even build and deploy docker images of your software in the CI. This new possibility immediately leads to the following questions.

  • When should you build a docker image?
  • How should it you tag the docker image?

The answers depend on your project. However, I’d like to discuss a few general strategies which are in line with usual git release practices.

Overwrite latest

The most straightforward strategy is to build the images, tag them as latest and push them to the registry. The following snippet of a GitLab CI job pushes the image to the registry of the repository.

docker build -t ${CI_REGISTRY_IMAGE}:latest .
docker push ${CI_REGISTRY_IMAGE}:latest

At first, this might look like a good idea. The latest tag corresponds to the most recent commit. However, when you rerun a CI job (because you need to recreate some expired artifact or an old docker image), you will overwrite the latest image by an old version. The situation becomes even more difficult if you consider feature branches. Whenever someone pushes to a feature branch, you will overwrite the latest tag. If there are multiple active feature branches, it is difficult to understand which features are included in the current latest image. The set of features included in latest will change regularly.

Using GitLab’s environments improves the situation slightly. This way, GitLab tracks the latest deployment to the latest environment. However, it doesn’t solve the issue; it merely helps to keep track of the problem.

Use Git commit hash

Another approach, which is frequently recommended in online articles, is to use the Git hash value. The relevant code for a GitLab CI job looks as follows.

docker build -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA} .
docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}

Since Git commit hashes are unique, we can be assured that images will not be overwritten. There is no problem in working on several feature branches at the same time.

This solution is not optimal. When should we build the images? For every commit? This would quickly lead to a cluttered registry. What about the latest tag? With this strategy, we have to deal with latest manually or pass cryptic hash values to the target audience of the image.

Use tags

Docker uses tags to tag images, GitLab uses tags to tag commit. Sounds like a perfect match. Consider the following example .gitlab-ci.yml configuration. It builds on top of the previous solution. Images are tagged with the commit hash or with the Git tag if present.

stages:
  - build

# Template for docker-in-docker jobs, requires special runner with 'dind' tag
.dind: &dind_template
  image: docker:git
  stage: build
  tags:
    - dind
  services:
    - name: docker:dind
      alias: docker-in-docker
  variables:
    DOCKER_HOST: tcp://docker-in-docker:2375/

# For non-tagged commits, manually build the image with the commit hash
build:manual:
  <<: *dind_template
  when: manual

  # Use ${CI_COMMIT_SHORT_SHA} if you want to use shortend commit hashs
  script:
    - echo "${CI_REGISTRY_PASSWORD}" | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY}
    - docker build -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA} .
    - docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}

# For tagged commits, automatically build a tagged image.
build:tag:
  <<: *dind_template
  only: [tags]

  script:
    - echo "${CI_REGISTRY_PASSWORD}" | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY}
    - docker build -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} .
    - docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}

The above example is a great improvement over the simple, hash-based tagging strategy. Once you release version, say 1.3.7-alpha, the CI will automatically deploy the tagged image to your registry. This ensures that none of the images are overwritten. The manual option to build hash-based images avoids cluttering the registry. This strategy clearly defines a policy when to tag an image: Always when you tag a git commit.

However, there are also disadvantages to this method. It does not create latest images. You can create a git tag called latest, but this tag is intended to be immutable, i.e., the tag should always point to the same commit. That’s not we want for the latest tag. Tags in the docker world are mutable. If only Git had something like a rolling reference pointer which is updated when you add a new commit. Oh, wait.

Use release branches

Git branches are references that point to the latest commit in a sequence of commits. It is common to use release branches to handle different released versions. Assume we want to have the following docker image tags: latest, 3, 3.7 and3.7.2. For this example, assume that version 3.7.2 is the latest version. This means all of these docker tags should point to the same image.

To achieve the given task, we need to map this structure into Git branches. We can build on top of the previous tag-based solution. Once a Git commit is tagged with a full version identifier such as 3.7.2 or 3.7.2-alpha, we never want to update that tag. So all we need are branches for latest, major version and minor version. I’ll call them release/latest, release/3 and release/3.7, respectively. I have added a prefix to avoid name collisions.

Images with a full version identifiers (e.g. 3.7.2) are build via the tag-based solution; all other images are build from release branches.

stages:
  - build
  - deploy

# ... continuation of above snipped

# For commits on the release branches, automatically tag the image with the
# release name
tag:release_branch:
  <<: *dind_template
  stage: deploy
  only:
    - /^release\/.*$/

  environment:
    name: $CI_COMMIT_REF_NAME
    url: https://url/to/your/registry

  script:
    # Pull image with full version identifier
    - export UNIQUE_TAG=$(git describe)
    - echo "${CI_REGISTRY_PASSWORD}" | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY}
    - docker pull ${CI_REGISTRY_IMAGE}:${UNIQUE_TAG}
    - docker tag ${CI_REGISTRY_IMAGE}:${UNIQUE_TAG} ${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_NAME#release/}
    - docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_NAME#release/}

Example

I have prepared an example repository which implements the solution illustrated above. The images are simple “Hello World”-applications.