Docker Course
Private Docker Registry
A startup running five microservices hits Docker Hub's rate limits every Tuesday afternoon when all the CI pipelines run simultaneously. A financial services company can't use a public registry for compliance reasons. A team in a data centre with no reliable internet connection needs images to be available locally. All three reach the same conclusion: run your own registry.
Running a private registry isn't complicated — Docker ships an official registry:2 image that gets you a functional registry in under two minutes. The complexity comes from doing it correctly — TLS, authentication, storage, and knowing when a managed service like ECR is a better choice than self-hosting.
When to Run Your Own Registry
Good reasons to self-host
- Air-gapped or offline environments
- Compliance requirements (data sovereignty)
- Avoiding Docker Hub rate limits entirely
- Extremely high pull volume with bandwidth costs
- Full control over retention and deletion policies
- Pull-through cache for Docker Hub images
Better to use a managed service (ECR / ghcr.io)
- You don't want to manage TLS certificate renewal
- You don't want to handle storage redundancy
- Your infra is already on AWS/GCP/GitHub
- You need vulnerability scanning out of the box
- Small team — operational overhead isn't worth it
Spinning Up a Basic Registry
The official registry:2 image runs the Docker Distribution registry — the same open-source software that underpins many managed registries. In its basic form, no authentication, no TLS — useful for local development and testing within a trusted network.
docker run -d \
--name local-registry \
--restart unless-stopped \
-p 5000:5000 \
-v registry-data:/var/lib/registry \
registry:2
# -p 5000:5000 → registry listens on port 5000 by default
# -v registry-data → named volume for persistent image storage
# registry:2 → official Docker Distribution registry image
Unable to find image 'registry:2' locally
2: Pulling from library/registry
96526aa774ef: Pull complete
Digest: sha256:3f71055ad7c41728e...
Status: Downloaded newer image for registry:2
a3b7c9d1e5f2b8c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2
# Verify it's listening
curl http://localhost:5000/v2/
{}
# Empty JSON response means the registry API is up and accepting connections
What just happened?
The registry is running and listening on port 5000. The curl to /v2/ hit the registry's API endpoint — an empty JSON object {} is the correct success response, confirming the registry is healthy and ready to accept pushes and pulls. The registry stores all image data in the registry-data volume — images pushed to it survive container restarts.
Push and Pull from a Local Registry
# Tag an image with the local registry address
docker tag order-api:v1.0.0 localhost:5000/order-api:v1.0.0
# localhost:5000 is the registry address — this is how Docker knows where to push
# Push to the local registry
docker push localhost:5000/order-api:v1.0.0
# Pull from the local registry (from any machine that can reach port 5000)
docker pull localhost:5000/order-api:v1.0.0
# List all repositories in the registry via the API
curl http://localhost:5000/v2/_catalog
# List all tags for a specific image
curl http://localhost:5000/v2/order-api/tags/list
The push refers to repository [localhost:5000/order-api]
3a7f2c9e1b4d: Pushed
8b1c4e7a9d2f: Pushed
5d3a1b9c7e4f: Pushed
a3b7c9d1e5f2: Pushed
v1.0.0: digest: sha256:b3c5e7a9...
# curl http://localhost:5000/v2/_catalog
{"repositories":["order-api"]}
# curl http://localhost:5000/v2/order-api/tags/list
{"name":"order-api","tags":["v1.0.0"]}
What just happened?
The image pushed successfully to the local registry. The /v2/_catalog API endpoint lists every repository in the registry — order-api appeared immediately after the push. The /v2/order-api/tags/list endpoint shows all available tags. This REST API is part of the OCI Distribution Specification — the same API that Docker Hub, ECR, and every other registry exposes. You can automate image management, garbage collection, and auditing by calling these endpoints directly.
Securing the Registry with TLS and Authentication
The basic registry above has no authentication — anyone who can reach port 5000 can push and pull any image. For anything beyond a local development machine, that's unacceptable. A production-grade private registry needs TLS and basic authentication at minimum.
# Step 1 — Generate a bcrypt password file for basic auth
# Install htpasswd (part of apache2-utils on Ubuntu)
mkdir -p auth
htpasswd -Bbn registry-admin strongpassword123 > auth/htpasswd
# -B → use bcrypt hashing (required by the registry — MD5 is rejected)
# -b → take password from command line
# -n → output to stdout instead of a file (we redirect to the file)
# The resulting file contains: registry-admin:$2y$05$...
# docker-compose.yml — production-grade private registry
services:
registry:
image: registry:2
restart: unless-stopped
ports:
- "5000:5000"
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "Private Registry"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt
REGISTRY_HTTP_TLS_KEY: /certs/domain.key
REGISTRY_STORAGE_DELETE_ENABLED: "true" # allow image deletion via API
volumes:
- registry-data:/var/lib/registry # image storage
- ./auth:/auth:ro # htpasswd file
- ./certs:/certs:ro # TLS certificate and key
volumes:
registry-data:
# Authenticate before pushing to the secured registry
docker login registry.acmecorp.internal:5000
# Username: registry-admin
# Password: strongpassword123
# Push and pull work the same way — registry address as the image prefix
docker tag order-api:v1.0.0 registry.acmecorp.internal:5000/order-api:v1.0.0
docker push registry.acmecorp.internal:5000/order-api:v1.0.0
# Without authentication: Error response from daemon: no basic auth credentials # After docker login: Login Succeeded The push refers to repository [registry.acmecorp.internal:5000/order-api] 3a7f2c9e1b4d: Pushed 8b1c4e7a9d2f: Pushed 5d3a1b9c7e4f: Pushed a3b7c9d1e5f2: Pushed v1.0.0: digest: sha256:b3c5e7a9...
What just happened?
With authentication enabled, an unauthenticated push returns "no basic auth credentials" immediately. After docker login with valid credentials, the push proceeded normally. The registry validates credentials against the htpasswd file on every request. TLS ensures all traffic — including the credentials themselves — is encrypted in transit. The combination of TLS and basic auth is the minimum viable security posture for any registry accessible over a network.
Using a Registry as a Docker Hub Pull-Through Cache
One of the most practical uses of a self-hosted registry — configure it as a pull-through cache for Docker Hub. When a developer or CI server pulls node:18-alpine, the request goes to your local registry first. If it has the image cached, it serves it instantly with no Docker Hub rate limit consumed. If not, it pulls from Docker Hub, caches it, and serves it. Every subsequent pull is free and instant.
# docker-compose.yml — pull-through cache registry
services:
registry-cache:
image: registry:2
restart: unless-stopped
ports:
- "5001:5000" # different port to avoid conflict with main registry
environment:
REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io
# REGISTRY_PROXY_USERNAME: your-dockerhub-username # optional — higher rate limits
# REGISTRY_PROXY_PASSWORD: your-dockerhub-token # optional
volumes:
- registry-cache-data:/var/lib/registry
volumes:
registry-cache-data:
# Configure Docker daemon to use the cache for all Docker Hub pulls
# Edit /etc/docker/daemon.json on each machine that should use the cache:
{
"registry-mirrors": ["http://registry.acmecorp.internal:5001"]
}
# Restart Docker after editing daemon.json
sudo systemctl restart docker
# Now docker pull node:18-alpine pulls from the cache automatically
# First pull: cache miss → fetches from Docker Hub → stores locally
# All subsequent pulls: cache hit → serves from local registry instantly
Pull-through cache — how requests flow
docker pull node:18-alpine
registry:5001
miss → fetch + cache
registry-1.docker.io
only to cache misses
A team of 20 developers all pulling node:18-alpine hits Docker Hub once — not 20 times. Rate limit problem solved.
Self-Hosted Means You Own the Operational Burden
A self-hosted registry means you're responsible for TLS certificate renewal (Let's Encrypt certs expire every 90 days), disk space management (images accumulate fast — garbage collection is your job), high availability (if the registry server dies, every deployment in your organisation stops), and backups (the registry-data volume is a single point of failure). For small teams, AWS ECR or GitHub Container Registry are almost always the better choice.
Teacher's Note
The pull-through cache pattern solves Docker Hub rate limits elegantly and is worth running even if you use ECR for your own images. Ten minutes to set up, zero ongoing rate limit emergencies.
Practice Questions
1. The official Docker registry image (registry:2) listens on which port by default?
2. To list all repositories stored in a running registry via its REST API, which endpoint do you call?
3. A registry configured to forward pulls to Docker Hub and store the results locally — eliminating rate limit hits for repeated pulls — is called a what?
Quiz
1. After starting a registry container, you run curl http://localhost:5000/v2/ and get {}. What does this response mean?
2. A team wants to eliminate Docker Hub rate limit errors across their 30-person CI environment without changing individual pipeline scripts. The correct approach is:
3. A team decides to self-host a private registry instead of using ECR. The ongoing operational responsibilities they take on include:
Up Next · Lesson 31
Image Versioning & Tagging
Your registry is sorted — now let's establish a tagging strategy that makes rollbacks instant, makes deployments auditable, and stops the "which version is in production?" question forever.