GitLab 16 🦊 Building and Publishing Container Images with GitLab Registry
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
| Variable | Value |
|---|---|
CI_REGISTRY | Registry hostname |
CI_REGISTRY_USER | gitlab-ci-token |
CI_REGISTRY_PASSWORD | Job token |
CI_REGISTRY_IMAGE | Full repository path |
Authentication Commands
| Command | Purpose |
|---|---|
docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD | CI authentication |
docker login registry.gitlab.com -u <user> --password-stdin | Local with PAT |
docker login registry.gitlab.com -u <deploy-user> --password-stdin | Local with deploy token |
Build and Push
| Command | Purpose |
|---|---|
docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . | Build with SHA tag |
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA | Push SHA tag |
docker tag ... $CI_REGISTRY_IMAGE:latest | Add latest tag |
docker push $CI_REGISTRY_IMAGE:latest | Push latest tag |
Token Scopes
| Scope | Purpose |
|---|---|
read_registry | Pull images |
write_registry | Push 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
access forbidden on push | Token lacks write_registry | Use CI_REGISTRY_PASSWORD or PAT with scope |
unauthorized on push | Token expired | Token valid 5 min (self-managed) or 15 min (GitLab.com) |
| Image not in registry | Push job did not run | Check rules condition |
docker: not found | No DinD service | Add services: - docker:dind |
| Pull fails | No read_registry scope | Use 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
| Item | Value |
|---|---|
| Registry hostname | $CI_REGISTRY |
| Registry path | $CI_REGISTRY_IMAGE |
| CI username | $CI_REGISTRY_USER |
| CI password | $CI_REGISTRY_PASSWORD |
| Push scope | write_registry |
| Pull scope | read_registry |
| DinD service | docker:24.0.5-dind |
| Token expiry | 5 min (self-managed), 15 min (GitLab.com) |
| Cleanup policy | Settings → Packages and registries |
Key takeaways:
- The CI/CD variables are the authentication. The
$CI_REGISTRY_USERand$CI_REGISTRY_PASSWORDvariables are injected automatically. Thedocker logincommand with--password-stdinauthenticates the job without exposing the password . - The
$CI_REGISTRY_IMAGEis the correct path. It resolves toregistry.gitlab.com/group/project. An image tagged with an arbitrary path that does not match an existing project is rejected . - The push requires
write_registryscope. A personal access token with onlyread_registrycan pull but not push. TheCI_REGISTRY_PASSWORDhas both scopes for the job’s project . - The merge request job should not push. The
build-mrjob validates the Dockerfile without publishing. Thebuild-and-pushjob runs on the main branch and produces the deployable image . - The token expires quickly. A long push can fail with
unauthorizedif 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.
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!