DEV Community

Pavan Kumar Sadashiv
Pavan Kumar Sadashiv

Posted on

Slashing GitLab CI/CD Build Times by 80%: Container Registry Caching & Dependency Proxy

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:

  1. GitLab Container Registry with BuildKit Layer Caching: Reuses previously compiled image layers across ephemeral runners.
  2. 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)                     |
+-------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After:

default:
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:20-alpine
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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

  1. Always Use Dependency Proxy: Prefix images with ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX} to eliminate 429 Too Many Requests errors.
  2. Order Dockerfile Instructions by Volatility: Put OS packages first, dependencies second, and application code last.
  3. Use BUILDKIT_INLINE_CACHE=1 or Kaniko --cache=true: Persist layers in your GitLab Container Registry so ephemeral runners never build from scratch.

Resources & Links

What is your favorite trick for speeding up slow CI/CD pipelines? Drop a comment below!

Top comments (0)