Docker Hub and GitHub Container Registry are convenient, but sometimes you want images on your own infrastructure: private code, no pull rate limits, faster pulls from servers in the same data center, or full control over retention. The official registry image (the CNCF “Distribution” project) provides a lightweight registry you can run in minutes.
1. Run the registry
mkdir -p ~/registry/{data,auth} && cd ~/registry
Create a user with bcrypt-hashed credentials:
docker run --rm --entrypoint htpasswd httpd:2 -Bbn ci 'a-long-random-password' > auth/htpasswd
compose.yaml:
services:
registry:
image: registry:2
restart: unless-stopped
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Registry
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_STORAGE_DELETE_ENABLED: "true"
volumes:
- ./data:/var/lib/registry
- ./auth:/auth:ro
ports:
- "127.0.0.1:5000:5000"
docker compose up -d
2. TLS with Nginx
Docker requires HTTPS for remote registries (except explicitly configured “insecure” ones — don’t). Put Nginx in front:
server {
listen 80;
server_name registry.example.com;
client_max_body_size 0; # image layers can be large
chunked_transfer_encoding on;
location /v2/ {
proxy_pass http://127.0.0.1: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;
}
}
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d registry.example.com
client_max_body_size 0 removes Nginx’s upload limit — without it, pushing layers fails with 413 errors.
3. Push and pull
docker login registry.example.com
docker tag myapp:1.4 registry.example.com/myapp:1.4
docker push registry.example.com/myapp:1.4
On another server:
docker login registry.example.com
docker pull registry.example.com/myapp:1.4
In Compose files: image: registry.example.com/myapp:1.4.
List repositories and tags via the API:
curl -s -u ci:password https://registry.example.com/v2/_catalog
curl -s -u ci:password https://registry.example.com/v2/myapp/tags/list
4. CI/CD integration
In GitHub Actions or GitLab CI, store the registry credentials as secrets and docker login before pushing. Use separate accounts for CI (push) and servers (pull) where possible — the htpasswd backend doesn’t support per-user permissions, so for fine-grained access control look at Harbor (below).
5. Storage and cleanup
Images accumulate. Deleting a tag through the API only removes the reference; disk space is freed by garbage collection:
# delete a manifest by digest (from the Docker-Content-Digest header of a manifest request)
curl -u ci:password -X DELETE https://registry.example.com/v2/myapp/manifests/sha256:…
# then garbage-collect (stop pushes during GC)
docker compose exec registry registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
Plan disk space accordingly, or use an S3-compatible storage backend (REGISTRY_STORAGE=s3 with endpoint settings) — e.g. MinIO.
6. Pull-through cache for Docker Hub
A registry can also act as a mirror for Docker Hub, avoiding rate limits and speeding up repeated pulls on your servers:
environment:
REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io
Run it as a separate instance, then configure Docker daemons to use it in /etc/docker/daemon.json: {"registry-mirrors": ["https://mirror.example.com"]}. See Docker Hub rate limits.
7. Backups
Back up ./data (the image layers) and ./auth. Often you don’t need to back up images at all if CI can rebuild them from Git tags — decide which images are irreplaceable.
Alternatives
- Harbor — full-featured registry with projects, RBAC, vulnerability scanning, replication and a web UI. See Harbor registry.
- Gitea / GitLab built-in container registries — images live next to your code. See Gitea.
- Hosted registries (GHCR, Docker Hub) — zero maintenance.
For a feature-rich self-hosted option, see Harbor. A registry next to your servers — for example on a Storage VPS in the same location — makes deployments fast.