Introduction
Self-hosted GitLab ships with a built-in Container Registry. Store Docker images locally instead of pulling from Docker Hub. This guide covers setup, usage, and pitfalls — especially behind Cloudflare Tunnel.
Why Use GitLab Container Registry?
- Avoid Docker Hub rate limits - No pull restrictions
- Faster CI/CD pipelines - Images cached on your network
- Consistent versions - All projects share the same base images
- Single source of truth - Centralized image management
- Better security - Images stay in your infrastructure
Prerequisites
You need:
- GitLab EE/CE instance (version 15.0+)
- Admin access to GitLab
- Docker installed locally
- Basic Docker knowledge
Part 1: Enabling Container Registry
Step 1: Verify Registry is Enabled
Recent GitLab versions enable the Container Registry by default. To check:
- Navigate to Admin Area → Settings → General
- Expand Visibility and access controls
- Look for Container Registry settings
Or at project level:
- Go to any project
- Navigate to Deploy → Container Registry
- If the registry interface loads, it’s enabled
Step 2: Server Configuration (Admin)
If disabled, configure it at the server level.
For Omnibus GitLab, edit /etc/gitlab/gitlab.rb:
# Enable Container Registry
registry_external_url 'https://registry.gitlab.example.com'
# Or use the same domain with a different port
# registry_external_url 'https://gitlab.example.com:5050'
gitlab_rails['registry_enabled'] = true
gitlab_rails['registry_host'] = "registry.gitlab.example.com"
gitlab_rails['registry_port'] = "443"
Then reconfigure:
sudo gitlab-ctl reconfigure
If you’re using Cloudflare Tunnel or another reverse proxy, ensure the registry URL is properly configured in your tunnel settings. The registry uses different endpoints than the main GitLab application.
Step 3: Verify Registry Status
# Check registry status
sudo gitlab-ctl status registry
# Check registry configuration
sudo gitlab-rake gitlab:container_registry:check
Part 2: Creating a Centralized Docker Images Repository
Why Centralize Base Images?
One project stores all shared base images. Every other project pulls from it.
your-group/
├── docker-images/ ← Centralized image storage
│ └── Container Registry
│ ├── node:24.12.0-bookworm-slim
│ ├── python:3.12-slim
│ └── golang:1.21-alpine
│
├── project-a/ ← Uses centralized images
├── project-b/ ← Uses centralized images
└── project-c/ ← Uses centralized images
Benefits
| Aspect | Centralized | Per-Project |
|---|---|---|
| Storage | Single copy | Duplicates everywhere |
| Updates | Update once | Update each project |
| Consistency | Always same version | Can drift |
| CI Speed | Fast (cached) | Slower |
Step 1: Create the Project
- Create a new project:
your-group/docker-images - Initialize with a README
- Verify Container Registry is enabled for this project
Step 2: Set Up CI Pipeline for Image Uploads
Create .gitlab-ci.yml in your docker-images project:
stages:
- build
variables:
DOCKER_TLS_CERTDIR: '/certs'
# Node.js 24 (Recommended for most projects)
push-node-24:
stage: build
image: docker:24-cli
services:
- docker:24-dind
timeout: 30m
before_script:
- echo "Logging into GitLab Container Registry..."
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- echo "Pulling node:24.12.0-bookworm-slim from Docker Hub..."
- docker pull node:24.12.0-bookworm-slim
- echo "Tagging for GitLab registry..."
- docker tag node:24.12.0-bookworm-slim $CI_REGISTRY_IMAGE/node:24.12.0-bookworm-slim
- echo "Pushing to GitLab Container Registry..."
- docker push $CI_REGISTRY_IMAGE/node:24.12.0-bookworm-slim
- echo "Image available at: $CI_REGISTRY_IMAGE/node:24.12.0-bookworm-slim"
only:
- main
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
# Add more images as needed
push-python-312:
stage: build
image: docker:24-cli
services:
- docker:24-dind
timeout: 30m
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker pull python:3.12-slim
- docker tag python:3.12-slim $CI_REGISTRY_IMAGE/python:3.12-slim
- docker push $CI_REGISTRY_IMAGE/python:3.12-slim
only:
- main
Why use CI instead of manual push? GitLab CI runners typically have direct network access to the registry, bypassing any proxy limitations. This is especially important when using Cloudflare Tunnel or similar services.
Step 3: Trigger the Pipeline
git add .gitlab-ci.yml
git commit -m "ci: add docker image push pipeline"
git push origin main
Monitor the pipeline in GitLab UI. Images appear in Container Registry once the job completes.
Part 3: Using Images from Your Registry
In GitLab CI/CD Pipelines
Reference images by full registry URL:
# Use your centralized Node.js image
image: registry.gitlab.example.com/your-group/docker-images/node:24.12.0-bookworm-slim
stages:
- build
- test
- deploy
build:
stage: build
script:
- npm ci
- npm run build
In Local Development
# Login to your GitLab registry
docker login registry.gitlab.example.com
# Pull an image
docker pull registry.gitlab.example.com/your-group/docker-images/node:24.12.0-bookworm-slim
# Use in docker run
docker run -it --rm \
registry.gitlab.example.com/your-group/docker-images/node:24.12.0-bookworm-slim \
node --version
In Dockerfiles
FROM registry.gitlab.example.com/your-group/docker-images/node:24.12.0-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
Part 4: Authentication Methods
Method 1: Personal Access Token (Recommended for Users)
- Go to User Settings → Access Tokens
- Create token with scopes:
read_registry(pulling)write_registry(pushing)
- Login:
docker login registry.gitlab.example.com -u your-username -p your-token
Method 2: Deploy Token (For Automation)
- Go to Project Settings → Repository → Deploy tokens
- Create token with appropriate scopes
- Use in CI or automation scripts
Method 3: CI/CD Variables (Automatic)
GitLab CI provides these variables automatically:
| Variable | Description |
|---|---|
$CI_REGISTRY | Registry URL |
$CI_REGISTRY_USER | CI user for authentication |
$CI_REGISTRY_PASSWORD | CI password (token) |
$CI_REGISTRY_IMAGE | Full image path for the project |
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
Part 5: Image Naming Best Practices
Use Specific Version Tags
# Good - Specific and reproducible
node:24.12.0-bookworm-slim
# Avoid - Can change unexpectedly
node:24
node:latest
Include OS Variant
# Clear about the base OS
node:24.12.0-bookworm-slim # Debian Bookworm (stable)
node:24.12.0-alpine3.21 # Alpine Linux
python:3.12-slim # Minimal Debian
Image Variant Selection Guide
| Variant | Size | Use Case |
|---|---|---|
bookworm-slim | ~250 MB | Default - Best compatibility |
alpine | ~130 MB | Minimal size, some compatibility issues |
bookworm (full) | ~1.1 GB | Need build tools |
For most Node.js projects, bookworm-slim is the best choice. It’s significantly smaller than the full image while
maintaining excellent compatibility with npm packages.
Part 6: Special Considerations for Cloudflare Tunnel
Docker Registry pushes can break behind Cloudflare Tunnel.
The Problem
docker push registry.gitlab.example.com/group/project/image:tag
# Push starts, then fails with "unauthorized: authentication required"
Symptoms:
- Login succeeds
- Push starts, layers begin uploading
- Fails mid-transfer with “unauthorized” error
- Registry namespace created but empty
Why This Happens
Cloudflare Tunnel can cause:
- Upload size limits (100MB on free plan)
- Timeout restrictions on long-running requests
- Chunked transfer encoding issues
Solutions
Solution 1: Use GitLab CI (Recommended)
Runners on the internal network bypass the tunnel entirely:
push-image:
stage: build
image: docker:24-cli
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker pull node:24.12.0-bookworm-slim
- docker tag node:24.12.0-bookworm-slim $CI_REGISTRY_IMAGE/node:24.12.0-bookworm-slim
- docker push $CI_REGISTRY_IMAGE/node:24.12.0-bookworm-slim
Solution 2: Push from Internal Network
# Use internal GitLab URL directly
docker login http://192.168.x.x:5050
docker tag image:tag 192.168.x.x:5050/group/project/image:tag
docker push 192.168.x.x:5050/group/project/image:tag
Solution 3: Configure Cloudflare Tunnel
Add registry-specific settings to your cloudflared config:
# config.yml
ingress:
- hostname: registry.gitlab.example.com
service: http://localhost:5050
originRequest:
noTLSVerify: true
connectTimeout: 30s
tcpKeepAlive: 30s
noHappyEyeballs: true
- service: http_status:404
Solution 4: Separate Registry Exposure
Expose the registry through a different path:
- Direct port forwarding (port 5050)
- VPN for internal access
- Separate subdomain with different tunnel config
Part 7: Registry Management
Viewing Images
- Navigate to Deploy → Container Registry
- Browse images and tags
- View image details (size, layers, vulnerabilities)
Cleaning Up Old Images
Manual Cleanup
- Open Container Registry in GitLab UI
- Select images/tags to delete
- Click the trash icon
Automatic Cleanup Policy
- Settings → Packages and registries → Container Registry tag expiration policy
- Configure:
- Keep tags matching:
latest,v\d+\.\d+\.\d+ - Remove tags older than 90 days
- Keep most recent 10 tags
- Keep tags matching:
Storage Monitoring
Check usage at Project Settings → General → Storage, or via API:
curl --header "PRIVATE-TOKEN: <token>" \
"https://gitlab.example.com/api/v4/projects/<project-id>" | jq '.statistics'
Part 8: Troubleshooting
Issue: “unauthorized: authentication required”
During login:
- Verify token scopes
- Check token expiry
- Confirm registry URL
During push (after successful login):
- Check storage quota
- Verify project permissions (Developer role or higher)
- Try GitLab CI instead of manual push
- If using Cloudflare Tunnel, see Part 6
Issue: “manifest unknown”
- Verify the image tag exists
- Check image name spelling
- Confirm you’re logged in
Issue: Slow pulls in CI
- Put runners on the same network as the registry
- Check runner cache config
- Use Dependency Proxy for external images
Debugging Commands
# Check Docker credentials
cat ~/.docker/config.json | jq
# Test registry authentication
curl -u "username:token" https://registry.gitlab.example.com/v2/
# Check GitLab registry logs (server access required)
sudo gitlab-ctl tail registry
Part 9: Automated Image Updates
Schedule Regular Updates
- Go to CI/CD → Schedules
- Create schedule: “Weekly base image updates”
- Set cron:
0 2 * * 0(Sundays at 2 AM) - Target branch:
main
Update Workflow
When new versions drop:
- Update image tags in the CI pipeline
- Test in a feature branch
- Merge if tests pass
- Pipeline pushes new images automatically
# Example: Adding a new Node.js version
push-node-24-13:
extends: .push-image-template
variables:
SOURCE_IMAGE: node:24.13.0-bookworm-slim
TARGET_TAG: node:24.13.0-bookworm-slim
Conclusion
GitLab Container Registry keeps Docker images inside your infrastructure. Centralize base images to skip Docker Hub rate limits, speed up pipelines, and lock down versions.
Key takeaways:
- Use a centralized project for base images
- Push via CI pipelines, especially behind proxies
- Pin specific version tags for reproducibility
- Set up cleanup policies to manage storage
- Watch for Cloudflare Tunnel issues if applicable