Slashing GitLab CI/CD Build Times by 80%: Container Registry Caching & Dependency Proxy
Nothing kills developer momentum faster than waiting 12 minutes for a CI/CD pipeline to rebuild the exact same Docker layers on every single git commit.
Because GitLab CI/CD jobs execute inside fresh, ephemeral runner environments, the local Docker daemon cache starts completely empty on every run. Worse yet, pulling base images repeatedly from Docker Hub quickly triggers the dreaded 429 Too Many Requests: toomanyrequests rate-limit error.
Fortunately, GitLab includes two built-in infrastructure features that completely solve both problems:
- GitLab Container Registry with BuildKit Layer Caching: Reuses previously compiled image layers across ephemeral runners.
- GitLab Dependency Proxy: Acts as a local pull-through cache for upstream Docker Hub images, eliminating rate limits and cutting network transfer times.
In this practical guide, I will show you how to combine these techniques to cut your container build times by up to 80%.
1. The Architecture: Cold Builds vs. Cached Pipeline
+-------------------------------------------------------------+
| NAIVE DOCKER-IN-DOCKER BUILD |
| |
| 1. Pull python:3.12 from Docker Hub (Rate limited!) |
| 2. apt-get update && install build-essential (3 mins) |
| 3. pip install 85 packages from scratch (5 mins) |
| 4. Copy source code & push image (2 mins) |
| Total Time: ~10+ Minutes on every single commit! |
+-------------------------------------------------------------+
vs.
+-------------------------------------------------------------+
| OPTIMIZED GITLAB CACHED PIPELINE |
| |
| 1. Pull base image via GitLab Dependency Proxy (2 secs) |
| 2. Fetch cached OS & pip layers from GitLab Registry |
| 3. Rebuild ONLY the changed application source layer |
| Total Time: ~45 Seconds! (85%+ faster) |
+-------------------------------------------------------------+
2. Step 1: Bypassing Docker Hub Rate Limits with Dependency Proxy
GitLab provides a built-in Dependency Proxy at the group level. When you prefix your image names with ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}, GitLab caches the upstream image on its own storage infrastructure.
Before:
default:
image: node:20-alpine # Vulnerable to Docker Hub 100 pulls / 6hr limit
After:
default:
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:20-alpine
Not only does this completely bypass Docker Hub rate limits without needing a paid Docker Hub subscription, it also pulls the image over GitLab’s internal high-speed backbone.
3. Step 2: Structuring Your Dockerfile for Layer Reuse
Docker caches layers sequentially from top to bottom. If an early layer changes, every subsequent layer is invalidated.
Always copy dependency manifests (package.json, poetry.lock, Cargo.lock, go.mod) before copying your source code:
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
# 1. Install OS packages (Rarely changes -> 99% cache hit)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
# 2. Install Python dependencies (Changes occasionally -> 90% cache hit)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 3. Copy source code last (Changes on every commit -> Rebuilt in 2 seconds)
COPY src/ ./src/
CMD ["python", "src/main.py"]
4. Step 3: Enabling BuildKit Inline Cache in .gitlab-ci.yml
To share Docker layers across ephemeral runners, configure Docker BuildKit with BUILDKIT_INLINE_CACHE=1. This embeds cache metadata directly into the image pushed to your GitLab Container Registry:
stages:
- build
variables:
DOCKER_DRIVER: overlay2
DOCKER_BUILDKIT: 1
IMAGE_CACHE: $CI_REGISTRY_IMAGE:cache
IMAGE_FINAL: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
build_container:
stage: build
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:26.1.3
services:
- name: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:26.1.3-dind
alias: docker
before_script:
# Authenticate with GitLab Container Registry and Dependency Proxy
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- echo "$CI_DEPENDENCY_PROXY_PASSWORD" | docker login $CI_DEPENDENCY_PROXY_SERVER -u $CI_DEPENDENCY_PROXY_USER --password-stdin
script:
# Pull previous cache metadata if available
- docker pull $IMAGE_CACHE || true
# Build using inline cache from GitLab Container Registry
- docker build
--build-arg BUILDKIT_INLINE_CACHE=1
--cache-from $IMAGE_CACHE
--tag $IMAGE_CACHE
--tag $IMAGE_FINAL
.
# Push updated image and cache tag back to GitLab Registry
- docker push $IMAGE_CACHE
- docker push $IMAGE_FINAL
5. Alternative: Rootless Builds with Kaniko (No Privileged Mode)
If your security team prohibits running docker:dind (which requires --privileged containers), use Google Kaniko. Kaniko builds container images in userspace and natively supports GitLab Registry layer caching:
build_with_kaniko:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.0-debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}"
--cache=true
--cache-repo="${CI_REGISTRY_IMAGE}/cache"
Kaniko automatically checks ${CI_REGISTRY_IMAGE}/cache for matching layer hashes and skips rebuilding unchanged instructions!
6. Real-World Benchmark Results
We measured pipeline durations on a production Python/FastAPI + C++ microservice before and after applying these optimizations:
| Build Scenario | Uncached DinD | BuildKit + Dependency Proxy | Time Saved |
|---|---|---|---|
| Cold Build (First Run) | 9m 42s | 8m 15s | 15% |
| Code-Only Change | 9m 38s | 0m 48s | 91.6% faster |
| Dependency Update | 9m 45s | 2m 10s | 77.7% faster |
7. Key Takeaways
-
Always Use Dependency Proxy: Prefix images with
${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}to eliminate429 Too Many Requestserrors. - Order Dockerfile Instructions by Volatility: Put OS packages first, dependencies second, and application code last.
-
Use
BUILDKIT_INLINE_CACHE=1or Kaniko--cache=true: Persist layers in your GitLab Container Registry so ephemeral runners never build from scratch.
Resources & Links
- GitLab Profile: gitlab.com/hrlpavan
- GitLab Contributor Profile: contributors.gitlab.com/users/hrlpavan
- GitHub: @hrlpavan
What is your favorite trick for speeding up slow CI/CD pipelines? Drop a comment below!
Top comments (0)