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:
- The Docker client sends the request to the mirror instead of directly to Docker Hub
- If the mirror already has the image (cache hit) → returns it immediately, at local network speed
- 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-alpineonly 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.
