Speed Up Docker Pulls with Registry Mirror and Pull-through Cache: Fix for Slow International Connections

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Yesterday a teammate messaged me: “Hey, docker pull nginx has been running for 15 minutes and it’s still not done.” A classic problem that any developer working in Vietnam — or anywhere with unreliable international connectivity — has run into at least once.

Docker Hub has servers in the US and Europe. Every docker pull means you’re fetching data from halfway around the world. Alpine (~5MB) is fine. But pytorch/pytorch (~6GB)? Or a full microservices stack of 10–15 images pulled in parallel during CI? At that point, an unstable international connection isn’t just an inconvenience — it’s a real blocker.

I ran into this exact situation while deploying microservices for an e-commerce project — not just slow pulls, but one time a memory leak in a container took 2 days to track down. Turned out the image was partially downloaded, the layer cache was corrupted, and the container started with some files missing but didn’t throw a clear error. That’s when I got serious about Registry Mirror and Pull-through Cache as a proper solution.

What Are Registry Mirror and Pull-through Cache?

Registry Mirror

A registry mirror is an intermediary server that sits between your Docker client and Docker Hub. When you run docker pull nginx:latest:

  1. The Docker client sends the request to the mirror instead of directly to Docker Hub
  2. If the mirror already has the image (cache hit) → returns it immediately, at local network speed
  3. If the mirror doesn’t have it (cache miss) → the mirror fetches it from Docker Hub, caches it, then serves it to you

Pull-through Cache

Pull-through cache is simpler than it sounds: any image pulled through your registry is automatically cached. Next time someone pulls the same image — even from a different machine on the same network — it comes from local storage, no internet trip needed.

Why set up your own instead of using a public mirror?

  • Saves bandwidth when the whole team pulls the same image — 10 machines pulling node:20-alpine only consumes external bandwidth once
  • CI/CD pipelines run significantly faster (our team went from ~8 minutes down to ~90 seconds)
  • Bypasses Docker Hub rate limits — 100 pulls/6h on a free account is easy to hit when CI runs continuously
  • Works independently even when Docker Hub has an outage

In Practice: 3 Ways to Configure a Registry Mirror

Option 1: Use a Public Mirror (Quickest)

Just need a quick fix? You can use public mirrors. Downside: they’re often unreliable long-term and you have no control over their uptime. Open or create /etc/docker/daemon.json:

sudo nano /etc/docker/daemon.json

Add the following:

{
  "registry-mirrors": [
    "https://mirror.gcr.io"
  ]
}

Then restart Docker and verify:

sudo systemctl daemon-reload
sudo systemctl restart docker

# Verify the configuration has been applied
docker info | grep -A 5 "Registry Mirrors"

Option 2: Self-Host a Pull-through Cache with Docker Registry

The solution I use for my team — stable, fully controlled, and free if you already have an internal server or VPS. All you need is one machine running Docker.

Step 1: Create the registry configuration file

mkdir -p ~/docker-mirror && cd ~/docker-mirror
nano config.yml

Contents of config.yml:

version: 0.1
log:
  fields:
    service: registry

storage:
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true

http:
  addr: :5000
  headers:
    X-Content-Type-Options: [nosniff]

proxy:
  remoteurl: https://registry-1.docker.io
  # Uncomment if you need Docker Hub auth (Pro account or private images)
  # username: your_dockerhub_username
  # password: your_dockerhub_password

health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3

Step 2: Run the Registry with Docker Compose

Create docker-compose.yml:

version: "3.8"

services:
  registry-mirror:
    image: registry:2
    container_name: docker-mirror
    restart: always
    ports:
      - "5000:5000"
    volumes:
      - ./config.yml:/etc/docker/registry/config.yml:ro
      - registry-data:/var/lib/registry

volumes:
  registry-data:

Start it up and verify:

docker compose up -d

# Check if the registry is running
curl http://localhost:5000/v2/
# Expected output: {}

# View logs
docker logs docker-mirror -f

Step 3: Configure the Docker daemon to point to your new mirror

On the client machine (can be the same server or another machine on the same network), edit /etc/docker/daemon.json:

{
  "registry-mirrors": [
    "http://YOUR_SERVER_IP:5000"
  ],
  "insecure-registries": [
    "YOUR_SERVER_IP:5000"
  ]
}

Replace YOUR_SERVER_IP with the actual IP of the server running the registry, then restart Docker.

sudo systemctl daemon-reload
sudo systemctl restart docker

Step 4: Test it to see the difference

# First pull (cache miss — fetching from Docker Hub through the mirror)
time docker pull nginx:alpine

# Remove local image
docker rmi nginx:alpine

# Second pull (cache hit — serving from local mirror)
time docker pull nginx:alpine

The second pull will be noticeably faster. On our internal network, pull time dropped from ~45 seconds to ~3 seconds for Alpine.

Option 3: HTTPS with Nginx Reverse Proxy (Production-Ready)

HTTP in production has one specific annoyance: every client machine needs insecure-registries added to daemon.json. Managing 20 developer machines turns that into a maintenance nightmare fast. Putting Nginx reverse proxy with Let’s Encrypt in front solves it cleanly:

# Install certbot (Ubuntu/Debian)
sudo apt install certbot python3-certbot-nginx -y

# Obtain certificate
sudo certbot --nginx -d registry-mirror.yourdomain.com

Nginx configuration at /etc/nginx/sites-available/registry-mirror:

server {
    listen 443 ssl;
    server_name registry-mirror.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/registry-mirror.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/registry-mirror.yourdomain.com/privkey.pem;

    client_max_body_size 0;       # No size limit — required for large images
    chunked_transfer_encoding on;

    location / {
        proxy_pass http://localhost:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 900;   # 15 minutes — needed for large images
    }
}

Once you have HTTPS, daemon.json on each client becomes much cleaner:

{
  "registry-mirrors": [
    "https://registry-mirror.yourdomain.com"
  ]
}

No more insecure-registries needed.

Managing Cache Storage

Cache fills up faster than you’d expect. Our team’s registry hit around 25GB after 3 weeks — mostly from Node, Python base images and multiple tags of the same image. A few commands worth knowing:

# Check current cache size
docker exec docker-mirror du -sh /var/lib/registry

# Garbage collection — remove unreferenced layers
docker exec docker-mirror registry garbage-collect \
  /etc/docker/registry/config.yml

# Completely reset cache (remove everything)
docker compose down
docker volume rm docker-mirror_registry-data
docker compose up -d

Conclusion

Most teams only think about a registry mirror after sitting through a 20-minute CI pipeline build — or after seeing the monthly bandwidth bill. With a small server, even a $5/month VPS, you solve both problems for the whole team.

Concrete numbers from my setup: CI/CD spin-up time dropped from ~8 minutes to ~90 seconds. In a 2-week sprint with 20–30 deploys, that adds up to hours of savings every month — not to mention the frustration of staring at a frozen terminal.

One important caveat before you get started: this mirror only caches public images from Docker Hub. If your team also uses GitHub Container Registry (ghcr.io), AWS ECR, or Google Container Registry, you’ll need a separate mirror for each — replace proxy.remoteurl with the corresponding address and run it on a different port.

Share: