The first image our GitLab CI pipeline pushed to the built-in Container Registry never arrived. Every layer uploaded, then the push died on blob unknown to registry. Docker 29.7 and later can push the provenance attestation before the image manifest it points to (on our runs it did every time), and GitLab’s registry refuses that order, so every fresh tag failed until we switched default attestations off. After that, a registry build cache cut the build job from about 90 seconds to about 30.
This guide enables the GitLab Container Registry over HTTPS, registers a Docker executor runner with a glrt- token, builds and pushes an image with Docker-in-Docker over TLS tagged with the commit SHA and latest, measures caching and sets a cleanup policy. It was all run on GitLab CE 19.5.0, GitLab Runner 19.5.0 and Docker 29.9.1 on Ubuntu 26.04 in October 2026, then replayed on a clean VM, and every error below is one we hit.
Prerequisites
Everything ran on one host here, which keeps the lab small but isn’t how you’d run it for a team. Before you start, you’ll need these pieces in place:
- GitLab CE on HTTPS, from the GitLab CE guide for Ubuntu 26.04 or the 24.04 and Debian 13 one
- Docker CE on the runner host, from the Docker CE guide for Ubuntu 26.04
- An administrator account and a personal access token with the
apiscope - TCP 443 for GitLab and TCP 5050 for the registry reachable from runners and clients
curl,gitandjqwhere you run the commands
Sizing follows the work, not this page. Eleven minutes after a reboot, with no job running, free -m on our VM showed 2.6 GB of its 8 GB still available, and every concurrent Docker-in-Docker job adds a full Docker daemon plus the build, so a runner host doing real work needs its own CPU and memory budget and fast disk for image layers. Our lab was a single 4 vCPU, 8 GB VM with GitLab, the registry and the runner together; treat it as a floor for following along. The package install alone took 13 minutes 42 seconds on it.
This page stays on images and the registry rather than pipeline basics. When you want the design side, the books for learning CI/CD pipelines we keep recommending go further than any single tutorial.
How the pieces connect
The job never talks to the host’s Docker daemon. Each build job gets two containers from the runner: a docker:29.9.1-cli container that runs your script and a docker:29.9.1-dind service container with its own daemon, joined over TLS on port 2376 with client certificates shared through a /certs/client volume. The registry sits behind GitLab’s bundled NGINX on port 5050 and authenticates pushes with short-lived tokens from GitLab on 443:

That separation is why the build is safe to run on a shared runner: a job can only reach its own throwaway daemon, which disappears when the job ends. It is also why nothing is cached between jobs unless you push a cache somewhere, which the caching section measures.
Set reusable shell variables
The hostname and token appear in most commands below, so export them once. Our lab ran everything on the GitLab server itself; if your runner or client is another machine, export the values that section uses on that machine too. Create the token under your avatar, Edit profile > Access > Personal access tokens > Generate token > Legacy token, and tick the api scope:
export SITE_DOMAIN="gitlab.example.com"
export GL_TOKEN="glpat-your-token-here"
Reconnecting clears them, so export them again in any new shell. Confirm the token before relying on it, since every later call fails the same way if it’s wrong.
curl -s --header "PRIVATE-TOKEN: ${GL_TOKEN}" "https://${SITE_DOMAIN}/api/v4/version" | jq '{version}'
A version string means the hostname resolves, TLS verifies and the token is accepted.
{
"version": "19.5.0"
}
A 401 here means the token is wrong or expired. A certificate error means this machine doesn’t trust the GitLab certificate yet, so fix either one before you move on, because the runner and Docker will hit the same wall later.
Enable the Container Registry
If GitLab already gets its certificate from the built-in Let’s Encrypt integration, the registry is on port 5050 of your GitLab hostname and you can skip ahead to the UFW rule and the /v2/ check at the end of this section. Otherwise it stays off until you change the main configuration file, which you open first.
sudo vim /etc/gitlab/gitlab.rb
Make one of two edits. On a public server with port 80 open, you can let GitLab fetch a Let’s Encrypt certificate instead, which also switches the registry on at port 5050. We did not run this path, because Let’s Encrypt can’t issue for example.com, so check it against the GitLab SSL documentation before you rely on it:
letsencrypt['enable'] = true
letsencrypt['contact_emails'] = ['[email protected]']
With your own certificate, as in our lab, add this line near the existing external_url instead. The registry reuses GitLab’s certificate only when it sits at /etc/gitlab/ssl/gitlab.example.com.crt and .key, which is where ours was; a certificate anywhere else needs registry_nginx['ssl_certificate'] and registry_nginx['ssl_certificate_key'] set as well:
registry_external_url 'https://gitlab.example.com:5050'
Apply the change. A reconfigure takes a few minutes, and on our VM it took just under 3, so don’t interrupt it when it seems to hang on the database steps.
sudo gitlab-ctl reconfigure
Our first reconfigure failed in the registry database migration with ERROR: duplicate key value violates unique constraint "pg_type_typname_nsp_index" (SQLSTATE 23505), and the registry log kept repeating there are pending database migrations. Running sudo gitlab-ctl reconfigure a second time applied the migrations cleanly and the registry started. A second install on a clean VM did not hit the error at all, so treat it as an intermittent race rather than a step. On our fresh 19.5 install the registry logged using the metadata database, and that database is what the failed migration was creating. If UFW is active on your server, open the registry port as well.
sudo ufw allow 5050/tcp
Now ask the registry for its API root. An unauthenticated client is supposed to be turned away here, and the header tells you where to get a token.
curl -si "https://${SITE_DOMAIN}:5050/v2/" | grep -iE "^HTTP|www-authenticate"
A 401 pointing at /jwt/auth is the healthy answer. A 502 Bad Gateway means NGINX is up but the registry process is not, which is exactly what we saw after the failed migration; check sudo gitlab-ctl status registry and the log in /var/log/gitlab/registry/current.
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer realm="https://gitlab.example.com/jwt/auth",service="container_registry"
The registry now answers on https://gitlab.example.com:5050, and every project gets a Deploy > Container registry page. You don’t create repositories by hand, because the first push to a path creates it.
Register a Docker executor runner
Install GitLab Runner from GitLab’s package repository rather than Ubuntu’s archive, because the runner should track your GitLab minor version. The repository already publishes a build for Ubuntu 26.04.
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
Run that line on its own. When we pasted it together with the next two, sudo swallowed the rest of the paste and the install never ran, and apt does the same to anything pasted after it. Once the script prints Repository configured successfully., install the package:
sudo apt install -y gitlab-runner
Then check the version. It should match your GitLab minor release, and an older runner is the first suspect when a new CI keyword is ignored.
gitlab-runner --version
Ours reported the 19.5 stable branch.
Version: 19.5.0
Git revision: 70f5c12d
Git branch: 19-5-stable
GO version: go1.26.7
Built: 2026-10-14T17:52:25Z
OS/Arch: linux/amd64
Runners are now created on the server first, which hands you a glrt- authentication token, and only then registered on the host. In the UI that is Admin > CI/CD > Runners > Create instance runner; tick “Run untagged jobs”, create it and copy the token. The API does the same in one call and keeps the token in a variable.
export RUNNER_TOKEN=$(curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/user/runners" --data "runner_type=instance_type" \
--data "description=docker-runner" --data "tag_list=docker" --data "run_untagged=true" | jq -r .token)
echo "${RUNNER_TOKEN:0:5}"
The prefix should read glrt-. On a separate runner host, run export SITE_DOMAIN="gitlab.example.com" and export RUNNER_TOKEN="glrt-your-token-here" there before registering, because the exports from the GitLab server don’t travel. Register the runner with the Docker executor. --docker-privileged is what lets the dind service start its own daemon, and the /certs/client volume is how the CLI container receives the TLS client certificates that daemon generates.
sudo gitlab-runner register --non-interactive \
--url "https://${SITE_DOMAIN}" \
--token "${RUNNER_TOKEN}" \
--executor "docker" \
--docker-image "docker:29" \
--docker-privileged \
--docker-volumes "/certs/client" \
--description "docker-runner"
The --docker-image "docker:29" value is only the fallback for jobs that name no image, and every job in this guide sets docker:29.9.1-cli through default:, so the fallback never decides the engine version here. Registration verifies the token against GitLab and writes /etc/gitlab-runner/config.toml. If you see is valid and the runner shows a green dot under Admin > CI/CD > Runners, jobs can start.
Verifying runner... is valid correlation_id=01M57XQZMFS448Z1NYR6BN8PBZ runner=p0YU5cgSo runner_name=docker-runner
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!
Configuration (with the authentication token) was saved in "/etc/gitlab-runner/config.toml"
Leave tags, locking and timeouts out of this command. With an authentication token those live on the server, and the old flags now abort registration. Adding --tag-list docker on our first attempt produced this.
FATAL: Runner configuration other than name and executor configuration is reserved (specifically --locked, --access-level, --run-untagged, --maximum-timeout, --paused, --tag-list, and --maintenance-note) and cannot be specified when registering with a runner authentication token. This configuration is specified on the GitLab server. Please try again without specifying any of those arguments. For more information, see https://docs.gitlab.com/ci/runners/new_creation_workflow/#changes-to-the-gitlab-runner-register-command-syntax
Old registration tokens are deprecated but not gone on self-managed 19.5. On our fresh install the admin setting that allows them was still on, and a --registration-token registration succeeded with the warning Support for registration tokens and runner parameters in the 'register' command has been deprecated in GitLab Runner 15.6. After we switched the setting off under Admin > Settings > CI/CD > Runners, the instance no longer had a registration token to give out and the same command stopped with PANIC: The registration-token needs to be entered. Use authentication tokens for new runners and switch the legacy option off once old automation is migrated.
Our runner host had to trust a private CA and resolve gitlab.example.com without DNS, so the lab needed extra steps that you skip with a public certificate and real DNS. First the CA files the runner, Docker and the dind containers read:
sudo mkdir -p /etc/gitlab-runner/certs "/etc/docker/certs.d/gitlab.example.com:5050" /etc/docker/certs.d/gitlab.example.com
sudo cp /usr/local/share/ca-certificates/lab-ca.crt /etc/gitlab-runner/certs/gitlab.example.com.crt
sudo cp /usr/local/share/ca-certificates/lab-ca.crt "/etc/docker/certs.d/gitlab.example.com:5050/ca.crt"
sudo cp /usr/local/share/ca-certificates/lab-ca.crt /etc/docker/certs.d/gitlab.example.com/ca.crt
That assumes the CA is already in the system store through update-ca-certificates. Then we replaced the volumes line in the [runners.docker] section of /etc/gitlab-runner/config.toml and added extra_hosts, so these were the two tested lines:
volumes = ["/certs/client", "/cache", "/etc/docker/certs.d:/etc/docker/certs.d:ro", "/etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro"]
extra_hosts = ["gitlab.example.com:10.0.1.50"]
Restart the runner so the jobs pick up the change with sudo systemctl restart gitlab-runner. Without the CA bundle mount, BuildKit’s registry cache failed with x509: certificate signed by unknown authority while fetching its token from /jwt/auth, because BuildKit uses the container’s system trust store rather than certs.d.
Pick a builder: dind, Buildah or kaniko
We build with Docker-in-Docker over TLS because it is the path GitLab’s own docs walk through, it runs the same docker build you use locally, and it isolates each job from the host. Its cost is privileged mode, and that rules it out on shared Kubernetes runners. Our verdict on the options follows.
| Builder | Needs | Status | Our take |
|---|---|---|---|
| Docker-in-Docker with TLS | Privileged runner, /certs/client volume | Documented by GitLab | Used here, tested end to end |
| Host socket binding | /var/run/docker.sock mounted into jobs | Documented | Jobs control the host daemon and see each other’s containers; avoid on shared runners |
| Buildah | No daemon, can run rootless | Maintained, recommended by GitLab as an alternative | The pick for unprivileged runners; not tested in this lab |
| kaniko | No daemon | Google archived the repository on 3 June 2025 | Do not start new pipelines on it |
GitLab’s docs now title their kaniko page “Use kaniko to build Docker images (removed)” and say “kaniko is no longer a maintained project”. Community forks exist, but we did not test any of them, so the guidance stays: dind where privileged runners are acceptable, Buildah where they are not.
Write the app, Dockerfile and pipeline
The image is a small Flask app, enough to prove the build, push and run steps. Create a group and a project for it, set a Git identity so git commit works on a fresh host, then clone the project. The token in the clone URL authenticates the later pushes, and Git stores it in plain text in .git/config, so the last section removes it again; an SSH remote avoids that:
export GROUP_ID=$(curl -s --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/groups/acme" | jq -r '.id // empty')
[ -z "${GROUP_ID}" ] && export GROUP_ID=$(curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/groups" --data "name=acme" --data "path=acme" | jq -r .id)
export PROJECT_ID=$(curl -s --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/acme%2Fflask-app" | jq -r '.id // empty')
[ -z "${PROJECT_ID}" ] && export PROJECT_ID=$(curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects" --data "name=flask-app" \
--data "namespace_id=${GROUP_ID}" --data "initialize_with_readme=true" | jq -r .id)
echo "group ${GROUP_ID}, project ${PROJECT_ID}"
git config --global user.name "Lab Admin"
git config --global user.email "[email protected]"
git clone "https://oauth2:${GL_TOKEN}@${SITE_DOMAIN}/acme/flask-app.git"
cd flask-app
Two numbers and a cloned directory mean you are ready. The group and project calls reuse acme and flask-app if they already exist, for example from our GitLab CI/CD variables and inputs guide, and only create them when they do not exist. Open the application file.
vim app.py
It answers on / with the version baked in at build time, and on /health for the smoke test. Keeping the version in the response lets you prove later which commit a running container came from.
import os
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def home():
return jsonify(app="flask-demo", version=os.getenv("APP_VERSION", "dev"))
@app.route("/health")
def health():
return jsonify(status="healthy")
Pin the two dependencies, so a rebuild next month installs the same code and the cached layer stays valid.
vim requirements.txt
These were the current releases on PyPI when we built. Bump them on purpose, not by accident.
flask==3.1.3
gunicorn==26.2.0
Next, create the Dockerfile in the same directory, because the pipeline builds from the repository root.
vim Dockerfile
The builder stage installs the packages into /install and the final stage copies only that directory, so pip’s build leftovers never reach the image. Dependencies are copied before the code on purpose: a code change then leaves the expensive pip install layer untouched, which is what makes the cache in a later section pay off. The container runs as nobody.
FROM python:3.14-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app.py .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
EXPOSE 5000
USER nobody
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]
Finally, open the pipeline file. GitLab reads it from the repository root on every push.
vim .gitlab-ci.yml
Both images are pinned to the exact Docker release we tested, because the push behaviour in the next section depends on the engine version and GitLab’s docs tell you to pin dind anyway. DOCKER_TLS_CERTDIR: "/certs" tells the dind service to generate certificates and the CLI image to use them, and with it set the docker:29.9.1-cli entrypoint finds the daemon at tcp://docker:2376 by itself, so no DOCKER_HOST is needed. CI_REGISTRY, CI_REGISTRY_USER and CI_REGISTRY_PASSWORD are predefined for every job once the registry is enabled. Each build gets two tags, the short commit SHA for traceability and latest for convenience, and the smoke test runs the SHA-tagged image, never latest, so it tests exactly what this pipeline built.
stages:
- build
- verify
variables:
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_SHA: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
default:
image: docker:29.9.1-cli # https://hub.docker.com/_/docker/tags
services:
- docker:29.9.1-dind # https://hub.docker.com/_/docker/tags
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
build-image:
stage: build
script:
- docker build --pull --provenance=false --build-arg APP_VERSION="$CI_COMMIT_SHORT_SHA" -t "$IMAGE_SHA" -t "$IMAGE_LATEST" .
- docker push "$IMAGE_SHA"
- docker push "$IMAGE_LATEST"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
smoke-test:
stage: verify
script:
- docker run -d --name app -p 5000:5000 "$IMAGE_SHA"
- sleep 3
- wget -qO- http://docker:5000/health
- wget -qO- http://docker:5000/
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The smoke test reaches the app at docker:5000 because the container runs inside the dind service, and the port is published on that service, not on the job container. Commit the four files and push, which starts the pipeline.
git add app.py requirements.txt Dockerfile .gitlab-ci.yml
git commit -m "Flask app with Docker build pipeline"
git push origin main
Both jobs run only on the default branch, so pushes to other branches create no pipeline for this file. Change the two rules: if you want images for merge requests too, and give those images their own tag prefix.
Read the build and push log
Open Build > Pipelines, the same view our first GitLab pipeline tutorial walks through in detail. A green run has two jobs, build-image then smoke-test, and on our runner the first one took about 90 seconds, much of it spent pulling images into a daemon that starts empty every time.

In the build-image log, look for Login Succeeded right after docker login and a digest: line after each push. Both tags should report the same digest, which proves latest and the SHA tag are one image.
$ docker push "$IMAGE_SHA"
The push refers to repository [gitlab.example.com:5050/acme/flask-app]
2dc42f2e8b6c: Pushed
98368dee2008: Pushed
d938f2472959: Pushed
4ede1430: digest: sha256:9c2f6c5640e75c9059acd95f4c8175a3aa91d97e15653c09ff94b53a2d47f2c7 size: 1622
$ docker push "$IMAGE_LATEST"
The push refers to repository [gitlab.example.com:5050/acme/flask-app]
latest: digest: sha256:9c2f6c5640e75c9059acd95f4c8175a3aa91d97e15653c09ff94b53a2d47f2c7 size: 1622
The smoke test then pulls that SHA tag back from the registry, starts it and calls both endpoints. The version in the second response is the commit that built it.
$ wget -qO- http://docker:5000/health
{"status":"healthy"}
$ wget -qO- http://docker:5000/
{"app":"flask-demo","version":"4ede1430"}
A failed smoke test leaves the image in the registry but marks the pipeline red, which is the signal to fix the build before anything deploys that tag.
Push fails with “blob unknown to registry”
Our first pipeline used plain docker build, and the push uploaded every layer and then failed with error from registry: blob unknown to registry - sha256:2a1877bb.... Docker 29 uses the containerd image store, and its default build wraps the image in an OCI index next to a provenance attestation manifest whose subject is the image manifest. Docker 29.7 and later push the two manifests concurrently, so the attestation can land first, and on our runs it did every time. GitLab’s registry then refuses a manifest whose subject it hasn’t seen yet. We caught the order in the registry log on the host’s Docker 29.9.1: the attestation’s PUT failed with code=MANIFEST_BLOB_UNKNOWN, and the image manifest’s own PUT then failed too as the client gave up. So every fresh tag failed for us, while a tag whose image manifest is already in the repository pushes its index fine, which is why it can look intermittent.
Upstream tracks it as moby/moby#53293, still open in October 2026, and the thread reports 29.6.2 as the last working release. Building with --provenance=false, as the pipeline above does, or exporting BUILDX_NO_DEFAULT_ATTESTATIONS=1, which also pushed cleanly in our test, is a workaround, not a fix, because you lose the attestation.
On the GitLab side the open issue is container-registry #2375. The OCI distribution spec says a registry “MUST initially accept an otherwise valid manifest with a subject field that references a manifest that does not exist in the repository”, and the registry fix that does so shipped in v4.42.0-gitlab, the version GitLab 19.5 bundles, behind the REGISTRY_FF_TOLERATE_MISSING_SUBJECT feature flag, which is off by default. GitLab.com switched it on in October 2026, but the registry source warns against enabling it in production until the follow-up subject_digest work is deployed, because referrers pushed before then can’t be repaired without a background migration. On a self-managed instance, keep --provenance=false and the pinned engine rather than flipping the flag.

That screenshot is a later build-image run with the cache enabled, showing the manifests and the cache export landing in the registry. You get there by changing only the build command, which the next section does.
Cache layers between builds
Each dind job starts with an empty daemon, so without help every build reinstalls the Python packages even when only app.py changed. BuildKit can store its cache in the registry and pull it back on the next run. Replace the script: of build-image with this version; mode=max exports the builder stage’s layers too, which matters here because the slow pip install happens in that stage, and --push pushes both tags as part of the build.
build-image:
stage: build
script:
- docker buildx build --pull --provenance=false
--cache-from "type=registry,ref=$CI_REGISTRY_IMAGE:buildcache"
--cache-to "type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max"
--build-arg APP_VERSION="$CI_COMMIT_SHORT_SHA"
-t "$IMAGE_SHA" -t "$IMAGE_LATEST" --push .
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
We timed the build-image job on the same runner, each run after a one-line change to app.py, reading the durations GitLab reports for the job and the whole pipeline. This is the first series, run on the original lab.
| Run | build-image job | Whole pipeline | pip install step |
|---|---|---|---|
| No cache, run 1 | 89.4 s | 106 s | rebuilt |
| No cache, run 2 | 93.1 s | 118 s | rebuilt, 12.4 s |
| Cache, first run (nothing cached yet) | 114.7 s | 144 s | rebuilt |
| Cache, warm run 1 | 36.7 s | 65 s | CACHED |
| Cache, warm run 2 | 29.3 s | 67 s | CACHED |
The warm runs cut the build job by roughly two thirds, which is more than the 12 seconds pip itself takes; we did not break down where the rest of the saving came from. In this series the first cached run was the slowest of all, about 25 seconds worse than no cache, because it found no cache (buildcache: not found) and then had to export one. Our clean replay on a freshly rolled-back VM measured 65.4 s without cache, 46.0 s for the first cached run and 37.2 s warm, so expect tens of seconds of run-to-run variance on a shared VM. After pinning the images to 29.9.1 we ran both files once more: 34.6 s without cache and 21.8 s warm, and the warm cache was the fastest build in every series. The whole-pipeline gain is smaller since the smoke test still pulls the image into a fresh daemon. A single-stage Dockerfile can get away with the simpler inline cache, but with multi-stage builds use mode=max or the builder stage reruns every time. The cache image showed up as a 49.47 MiB buildcache tag next to the 43.72 MiB application tags, so keep it out of cleanup rules.
Pull the image from a client
Anything outside a pipeline needs its own credentials, and a project deploy token with only read_registry is the narrowest thing that works: it can pull from this one project and nothing else. Create it and keep the token, which GitLab shows only once.
export DEPLOY_TOKEN=$(curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/deploy_tokens" \
--data "name=pull-only" --data "username=registry-reader" --data "scopes[]=read_registry" | jq -r .token)
echo "${DEPLOY_TOKEN:0:5}"
The prefix should read gldt-. Copy the token to the client, which needs Docker installed, and export it there together with the hostname:
export SITE_DOMAIN="gitlab.example.com"
export DEPLOY_TOKEN="gldt-your-token-here"
Log in with the token. Paste this line on its own, because sudo ate the next line when we pasted both together:
echo "${DEPLOY_TOKEN}" | sudo docker login "${SITE_DOMAIN}:5050" -u registry-reader --password-stdin
After Login Succeeded, pull the image. You can pull a SHA tag the same way when you need a specific build.
sudo docker pull "${SITE_DOMAIN}:5050/acme/flask-app:latest"
The pull ends with the digest and the image reference.
Digest: sha256:6b9f878d6a791f097076d9e3ad5cc96260531bfcfb3714a33888e1500960d357
Status: Downloaded newer image for gitlab.example.com:5050/acme/flask-app:latest
gitlab.example.com:5050/acme/flask-app:latest
Run it to confirm the image works away from the pipeline. Port 8000 on the client maps to the app’s 5000.
sudo docker run -d --rm --name flask-check -p 8000:5000 "${SITE_DOMAIN}:5050/acme/flask-app:latest"
Give Gunicorn a few seconds to start, then call the app. A refused connection right after the run usually means it’s still booting.
curl -s http://127.0.0.1:8000/
The response names the commit the image was built from, so you know exactly which build you’re running. Ours shows a different commit from the smoke test above because the pull came from our clean replay, a later build of the same files.
{"app":"flask-demo","version":"efb847b3"}
We also tried the wrong credentials on purpose, because the two failures read very differently. A pull with no login at all failed with Error response from daemon: error from registry: access forbidden, while a push with the read_registry deploy token got as far as the manifest and then stopped on failed commit on ref "manifest-sha256:...": server message: insufficient_scope: authorization failed.
access forbidden means GitLab saw no usable credentials, so check docker login for that exact host:port. insufficient_scope means the credentials were fine but the token’s scope or the user’s role does not allow the action; pushing needs a token with the write_registry scope. Stop the test container with sudo docker stop flask-check when you are done.
Fix Docker-in-Docker connection errors
Most dind failures are the CLI looking for a daemon in the wrong place. We reproduced the three common ones in a separate branch, and each job failed at its first docker command.
| What was wrong | Error in the job log | Fix |
|---|---|---|
No docker:29-dind under services: | failed to connect to the docker API at tcp://docker:2375: lookup docker on 10.0.1.1:53: no such host | Add the dind service; the host name docker only exists when it runs |
DOCKER_TLS_CERTDIR pointing at a path the runner does not share | Cannot connect to the Docker daemon at tcp://docker:2375. Is the docker daemon running? | Use "/certs" and keep /certs/client in the runner’s volumes |
TLS turned off with DOCKER_TLS_CERTDIR: "" but DOCKER_HOST still on 2376 | Cannot connect to the Docker daemon at tcp://docker:2376. Is the docker daemon running? | Keep TLS on and remove the hand-set DOCKER_HOST |
We reproduced the second row by pointing DOCKER_TLS_CERTDIR at /certs-not-mounted. A runner rebuilt without the /certs/client volume should fail the same way, because the CLI entrypoint finds no client certificates and falls back to port 2375, where the TLS-only daemon isn’t listening, but we did not run that variant. When in doubt, compare volumes in /etc/gitlab-runner/config.toml with the register command above.
Clean up old tags with a cleanup policy
Tagging every commit means the registry grows by one 44 MiB tag per push to main until something removes them. A cleanup policy does that on a schedule. Ours keeps the five newest tags of each image, always keeps latest and buildcache, and removes everything else regardless of age.
curl -s --request PUT --header "PRIVATE-TOKEN: ${GL_TOKEN}" --header "Content-Type: application/json" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}" \
--data '{"container_expiration_policy_attributes":{"enabled":true,"cadence":"1d","keep_n":5,"older_than":null,"name_regex":".*","name_regex_keep":"^(latest|buildcache)$"}}' \
| jq -c .container_expiration_policy
GitLab echoes the stored policy and its next run time. The first run happens a day later, so you won’t see tags disappear right away.
{"cadence":"1d","enabled":true,"keep_n":5,"older_than":null,"name_regex":".*","name_regex_keep":"^(latest|buildcache)$","next_run_at":"2026-10-19T19:03:46.582Z"}
The same form lives under Settings > Packages and registries: expand Container registry, then Container registry cleanup policies > Set cleanup rules. The page states that latest is always kept, so listing it in the keep pattern is belt and braces, while buildcache genuinely needs it because the cache tag would otherwise count as an ordinary old tag:

To see the policy act without waiting a day, we moved its next run into the past and triggered the scheduler worker from the Rails console. The image went from 10 tags to 7: the five newest SHA tags plus latest and buildcache survived, and the oldest SHA tag went along with two tags we had pushed by hand while debugging. The registry page then showed the remaining tags and the next scheduled run.

For production we’d add an age limit such as older_than: "14d", so a burst of pushes in one afternoon cannot rotate out the image that is actually deployed, and keep release tags with a pattern like ^v\d+\.\d+\.\d+$ in the keep rule. Deleting tags does not by itself free disk on every setup; GitLab’s guide to reducing container registry storage covers when the freed layers are actually reclaimed.
Once you have finished pushing, go back to the flask-app directory and take the token back out of its Git configuration, because anyone who can read the directory can read an admin token there:
git remote set-url origin "https://${SITE_DOMAIN}/acme/flask-app.git"
git remote -v
Both lines should now show the URL without credentials, and the next push will ask for them or use your credential helper.