| | |

GitLab 16 🦊 Building and Publishing Container Images with GitLab Registry

⏱️ Estimated read: 12 min | |

The GitLab Container Registry is the built-in registry for storing Docker images and OCI artifacts alongside the source code. It is not an external service like Docker Hub; it is a component of the GitLab instance, accessible at registry.gitlab.com on GitLab.com or at a configured hostname on self-managed instances. The exam tests the CI/CD integration: the predefined variables that authenticate the job, the docker login command, the docker build and docker push sequence, and the distinction between a merge request validation build (which does not push) and a branch build (which does). The critical trap is the authentication scope: a token must have read_registry for pulling and write_registry for pushing. A token with only read_registry fails at the push step with access forbidden .

Key point: The CI_REGISTRY_USER and CI_REGISTRY_PASSWORD variables are automatically provided in every CI/CD job. The password is a short-lived token valid only for the duration of the job. The CI_REGISTRY_IMAGE variable is the full path to the project’s registry repository (registry.gitlab.com/group/project). The docker login command authenticates, and the docker push command uploads the tagged image.


Why the GitLab Registry exists

The external registry problem. A project hosted on GitLab that stores its images on Docker Hub requires a separate account, a separate authentication token, and a separate rate limit. The Docker Hub pull rate limit for anonymous users is a common source of CI failures. The GitLab Container Registry eliminates this external dependency: the images are stored in the same platform as the code, authenticated with the same job token, and subject to the same access controls .

The merge request validation problem. A build job that runs on every merge request should verify that the Dockerfile is valid, not publish a new image. The GitLab documentation recommends a two-job pattern: a build-mr job that runs on merge requests and builds the image locally without pushing, and a build-and-push job that runs on the main branch and pushes the image to the registry. The MR job provides early feedback; the main branch job produces the deployable artifact .

The authentication problem. A CI/CD job does not need a personal access token to authenticate with the registry. The CI_REGISTRY_USER and CI_REGISTRY_PASSWORD variables are injected automatically. The password is the CI_JOB_TOKEN, which is valid only for the duration of the job and is scoped to the project’s registry . For external systems that need long-lived access, a deploy token with read_registry and write_registry scopes is the correct mechanism .

The image naming problem. The registry path must match the project namespace. The CI_REGISTRY_IMAGE variable provides the correct prefix: registry.gitlab.com/group/project. An image tagged as $CI_REGISTRY_IMAGE/my-app:latest is valid. An image tagged with an arbitrary namespace that does not correspond to an existing project is rejected with access denied .

The trade-off. The registry stores images inside the GitLab instance, which consumes storage. GitLab provides cleanup policies to remove old tags based on age, name pattern, and a keep count . A project that pushes on every commit without a cleanup policy will accumulate images indefinitely. The latest tag should not be the only tag; the commit SHA tag provides a unique reference for each build.


a. The CI/CD variables and authentication

The authentication uses the predefined variables. The before_script block is the correct place for the login command, because every job that needs the registry must authenticate.

before_script:
  - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin

The $CI_REGISTRY variable is the registry hostname. The $CI_REGISTRY_USER is gitlab-ci-token. The $CI_REGISTRY_PASSWORD is the job token. The --password-stdin flag avoids exposing the password in the process list .

The $CI_REGISTRY_IMAGE variable is the full repository path. An image tagged as $CI_REGISTRY_IMAGE:latest is pushed to the project’s registry.

script:
  - docker build -t $CI_REGISTRY_IMAGE:latest .
  - docker push $CI_REGISTRY_IMAGE:latest

The $CI_REGISTRY_IMAGE is equivalent to $CI_REGISTRY/$CI_PROJECT_PATH. For a project at group/project, the registry path is registry.gitlab.com/group/project .


b. The build and push workflow

The build workflow depends on the trigger. On a merge request, the image is built but not pushed. On the main branch, the image is built and pushed.

stages:
  - build

build-mr:
  stage: build
  image: docker:24.0.5-cli
  services:
    - docker:24.0.5-dind
  script:
    - docker build -t my-app:$CI_COMMIT_SHORT_SHA .
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

build-and-push:
  stage: build
  image: docker:24.0.5-cli
  services:
    - docker:24.0.5-dind
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

The build-mr job does not authenticate. It builds the image locally with a temporary tag. The build-and-push job authenticates, builds with the commit SHA tag, and pushes. The rules keyword ensures that the push only happens on the main branch .

The Docker-in-Docker (DinD) service is required for the docker commands to work inside the job container. The services: - docker:24.0.5-dind line starts a Docker daemon alongside the job, and the image: docker:24.0.5-cli provides the Docker client .


c. Tagging strategies and pulling the image

The tag identifies the image version. The commit SHA is unique per commit. The latest tag is a moving pointer to the most recent build. The branch name tag is useful for testing environments.

script:
  - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE:latest .
  - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  - docker push $CI_REGISTRY_IMAGE:latest

The docker build command with multiple -t flags tags the same image with multiple names. The docker push command with multiple tags uploads the image once and assigns both tags.

To pull the image from another system, the authentication uses a personal access token or a deploy token.

# Pull with a personal access token
echo "<token>" | docker login registry.gitlab.com -u <username> --password-stdin
docker pull registry.gitlab.com/group/project:latest

The personal access token must have read_registry scope. The user must have at least Reporter access to the project if the registry visibility is “Only Project Members” . A deploy token with read_registry scope is the alternative for automated systems that do not have a user account .


Complete Example Session

# ============================================
# PART 1: CREATE THE PIPELINE
# ============================================

cat > .gitlab-ci.yml <<'EOF'
stages:
  - build

build-mr:
  stage: build
  image: docker:24.0.5-cli
  services:
    - docker:24.0.5-dind
  script:
    - docker build -t my-app:$CI_COMMIT_SHORT_SHA .
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

build-and-push:
  stage: build
  image: docker:24.0.5-cli
  services:
    - docker:24.0.5-dind
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
EOF
# ============================================
# PART 2: COMMIT AND PUSH
# ============================================

git add .gitlab-ci.yml
git commit -m "Add container registry pipeline"
git push origin main
# ============================================
# PART 3: OBSERVE THE PIPELINE
# ============================================

# Build → Pipelines → click the pipeline
# The build-and-push job runs on main.
# ============================================
# PART 4: VERIFY THE IMAGE IN THE REGISTRY
# ============================================

# Packages & Registries → Container Registry
# The image appears with two tags:
# - latest
# - <commit-sha>
# ============================================
# PART 5: PULL THE IMAGE LOCALLY
# ============================================

echo "<token>" | docker login registry.gitlab.com -u <username> --password-stdin
docker pull registry.gitlab.com/group/project:latest
# ============================================
# PART 6: CREATE A DEPLOY TOKEN
# ============================================

# Settings → Repository → Deploy tokens → Add token
# Name: registry-read
# Scopes: read_registry
# Create deploy token
# Copy the username and token.
# ============================================
# PART 7: PULL WITH DEPLOY TOKEN
# ============================================

echo "<deploy-token>" | docker login registry.gitlab.com -u <deploy-user> --password-stdin
docker pull registry.gitlab.com/group/project:latest
# ============================================
# PART 8: OBSERVE THE MR JOB
# ============================================

# Create a merge request.
# The build-mr job runs and builds the image locally.
# No push occurs.
# ============================================
# PART 9: CONFIGURE CLEANUP POLICY
# ============================================

# Settings → Packages and registries → Container registry
# Cleanup policy:
# - Keep the most recent: 10
# - Remove tags older than: 30 days
# Save

The nine parts trace the registry workflow: pipeline creation, commit, pipeline observation, registry verification, local pull, deploy token creation, pull with deploy token, MR job observation, and cleanup policy.


Quick Reference

Registry Variables

VariableValue
CI_REGISTRYRegistry hostname
CI_REGISTRY_USERgitlab-ci-token
CI_REGISTRY_PASSWORDJob token
CI_REGISTRY_IMAGEFull repository path

Authentication Commands

CommandPurpose
docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORDCI authentication
docker login registry.gitlab.com -u <user> --password-stdinLocal with PAT
docker login registry.gitlab.com -u <deploy-user> --password-stdinLocal with deploy token

Build and Push

CommandPurpose
docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .Build with SHA tag
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHAPush SHA tag
docker tag ... $CI_REGISTRY_IMAGE:latestAdd latest tag
docker push $CI_REGISTRY_IMAGE:latestPush latest tag

Token Scopes

ScopePurpose
read_registryPull images
write_registryPush images (also requires read)

Best Practices

✅ Do This:

echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
<!-- Use --password-stdin to avoid process list exposure --> # ✅
docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
<!-- Tag with commit SHA for uniqueness -->              # ✅
rules:
  - if: $CI_COMMIT_BRANCH == "main"
<!-- Only push on main branch -->                     # ✅

❌ Don’t Do This:

# Push without authentication
<!-- The registry rejects unauthenticated pushes -->   # ❌
# Use a token with only read_registry for pushing
<!-- Push fails with access forbidden -->            # ❌
# Push to an arbitrary namespace
<!-- The registry rejects unknown project paths -->    # ❌

Common Pitfalls

PitfallWhy It HappensFix
access forbidden on pushToken lacks write_registryUse CI_REGISTRY_PASSWORD or PAT with scope
unauthorized on pushToken expiredToken valid 5 min (self-managed) or 15 min (GitLab.com)
Image not in registryPush job did not runCheck rules condition
docker: not foundNo DinD serviceAdd services: - docker:dind
Pull failsNo read_registry scopeUse PAT or deploy token with read scope

Real-World Examples

1. CI Authentication

before_script:
  - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin

2. Build and Push

script:
  - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

3. Latest Tag

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

4. MR Validation

script:
  - docker build -t my-app:$CI_COMMIT_SHORT_SHA .
rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"

5. Deploy Token

Settings → Repository → Deploy tokens → read_registry

6. Pull with Deploy Token

echo "<token>" | docker login registry.gitlab.com -u <deploy-user> --password-stdin

7. Pull with PAT

echo "<token>" | docker login registry.gitlab.com -u <username> --password-stdin

8. Cleanup Policy

Settings → Packages and registries → Container registry
Keep most recent: 10
Remove older than: 30 days

9. Registry View

Packages & Registries → Container Registry

10. DinD Service

services:
  - docker:24.0.5-dind

Visual

Build and Push Flow

┌──────────────────────────────────────────────┐
│  GitLab CI/CD job                            │
│  image: docker:24.0.5-cli                    │
│  services: docker:24.0.5-dind                │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  docker login                                │
│  $CI_REGISTRY_USER / $CI_REGISTRY_PASSWORD   │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  docker build                                │
│  -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA  │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  docker push                                 │
│  registry.gitlab.com/group/project:sha       │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  GitLab Container Registry                   │
│  Packages & Registries → Container Registry  │
└──────────────────────────────────────────────┘

MR vs Main Branch

┌──────────────────────────────────────────────┐
│  Merge Request                               │
│  build-mr job                                │
│  ├── docker build                            │
│  └── no push                                 │
│  (validation only)                           │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Main Branch                                 │
│  build-and-push job                          │
│  ├── docker login                            │
│  ├── docker build                            │
│  └── docker push                             │
│  (deployable image)                          │
└──────────────────────────────────────────────┘

Summary

ItemValue
Registry hostname$CI_REGISTRY
Registry path$CI_REGISTRY_IMAGE
CI username$CI_REGISTRY_USER
CI password$CI_REGISTRY_PASSWORD
Push scopewrite_registry
Pull scoperead_registry
DinD servicedocker:24.0.5-dind
Token expiry5 min (self-managed), 15 min (GitLab.com)
Cleanup policySettings → Packages and registries

Key takeaways:

  • The CI/CD variables are the authentication. The $CI_REGISTRY_USER and $CI_REGISTRY_PASSWORD variables are injected automatically. The docker login command with --password-stdin authenticates the job without exposing the password .
  • The $CI_REGISTRY_IMAGE is the correct path. It resolves to registry.gitlab.com/group/project. An image tagged with an arbitrary path that does not match an existing project is rejected .
  • The push requires write_registry scope. A personal access token with only read_registry can pull but not push. The CI_REGISTRY_PASSWORD has both scopes for the job’s project .
  • The merge request job should not push. The build-mr job validates the Dockerfile without publishing. The build-and-push job runs on the main branch and produces the deployable image .
  • The token expires quickly. A long push can fail with unauthorized if the token expires mid-transfer. The default is 5 minutes on self-managed and 15 minutes on GitLab.com .

Remember: The GitLab Container Registry is the built-in registry. The $CI_REGISTRY_IMAGE is the address. The $CI_REGISTRY_PASSWORD is the key. The docker build and docker push are the commands. The rules keyword controls when the push happens. The cleanup policy controls how long the images are kept.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!