Open to full-time roles, freelance projects, or just a good conversation about tech. Drop me a line and let's talk.

Running your own Docker registry gives you full control over your images — no rate limits, no storage fees, and no dependency on third-party services. In this guide, we'll set up a private Docker Registry v2 behind Nginx with a proper SSL certificate.

Before jumping in, here's why you might want to run your own registry:
| Docker Hub | Self-Hosted | |
|---|---|---|
| Pull rate limit | Yes (free tier) | None |
| Storage cost | Paid per GB | Your server |
| Privacy | Public unless paid | Fully private |
| Network speed | Depends on internet | Local/internal |
| Control | Limited | Full |
If you're running a microservices setup with dozens of images — like an SSO platform with 12+ services — the cost and rate limits of cloud registries add up fast.
registry.your-company.com)Docker Registry v2 is available as an official image. Create a docker-compose.yaml for the registry:
services:
registry:
image: registry:2
container_name: registry
restart: unless-stopped
volumes:
- registry_data:/var/lib/registry
networks:
- nginx_network
volumes:
registry_data:
networks:
nginx_network:
external: trueStart it:
docker compose up -d registryThe registry now runs on port 5000 internally, but is not exposed to the outside yet — Nginx will handle that.

The registry needs specific Nginx settings to work correctly — particularly client_max_body_size 0 (no upload limit) and disabled proxy buffering for large layer uploads.
Create /etc/nginx/conf.d/registry.conf:
server {
listen 80;
server_name registry.your-company.com;
# Required for certbot SSL challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name registry.your-company.com;
ssl_certificate /etc/letsencrypt/live/registry.your-company.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.your-company.com/privkey.pem;
# No upload size limit — Docker layers can be hundreds of MBs
client_max_body_size 0;
proxy_read_timeout 900;
proxy_send_timeout 900;
proxy_connect_timeout 900;
location / {
proxy_pass http://registry:5000;
proxy_http_version 1.1;
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 https;
# Required header for Docker Registry v2
proxy_set_header Docker-Distribution-Api-Version registry/2.0;
# Critical: disable buffering for large layer uploads
proxy_buffering off;
proxy_request_buffering off;
chunked_transfer_encoding on;
}
}Important:
proxy_buffering offandproxy_request_buffering offare critical. Without these, Nginx will try to buffer large Docker layers in memory/disk before forwarding — causing broken pipe errors and500responses on large pushes.
We'll use Certbot with the webroot method so Nginx doesn't need to stop.
Make sure Nginx is running and serving the /.well-known/acme-challenge/ path (already configured above), then run:
docker run --rm \
-v /path/to/certbot/conf:/etc/letsencrypt \
-v /path/to/certbot/www:/var/www/certbot \
certbot/certbot certonly \
--webroot -w /var/www/certbot \
--email [email protected] \
-d registry.your-company.com \
--agree-tosCertbot stores certificates as symlinks pointing to the archive/ directory:
live/registry.your-company.com/
├── fullchain.pem → ../../archive/registry.your-company.com/fullchain1.pem
├── privkey.pem → ../../archive/registry.your-company.com/privkey1.pem
└── ...
If Nginx runs in Docker, you must mount both directories — otherwise the symlinks resolve to dead paths inside the container:
volumes:
- /path/to/certbot/conf/live/registry.your-company.com:/etc/letsencrypt/live/registry.your-company.com:ro
- /path/to/certbot/conf/archive/registry.your-company.com:/etc/letsencrypt/archive/registry.your-company.com:roReload Nginx:
docker exec nginx nginx -s reloadIf your domain uses Cloudflare, do not proxy the registry subdomain (keep the orange cloud off for registry.your-company.com).

Cloudflare's proxy introduces two problems for Docker registries:
500 errors mid-push with a broken pipe.Set the registry DNS record to DNS Only (grey cloud). Your other domains can remain proxied — this only affects the registry subdomain.
Test that the registry is reachable:
curl -s https://registry.your-company.com/v2/
# Expected: {}Tag and push an image:
docker tag my-app:latest registry.your-company.com/my-org/my-app:v1.0.0
docker push registry.your-company.com/my-org/my-app:v1.0.0List all repositories:
curl -s https://registry.your-company.com/v2/_catalog | jq .List tags for a specific image:
curl -s https://registry.your-company.com/v2/my-org/my-app/tags/list | jq .By default, the registry is open to anyone who can reach it. Add basic auth:
# Generate htpasswd credentials
docker run --rm --entrypoint htpasswd httpd:2 -Bbn username password > auth/htpasswdUpdate docker-compose.yaml:
registry:
image: registry:2
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
- ./auth:/auth
- registry_data:/var/lib/registryLogin before pushing:
docker login registry.your-company.com500 broken pipe when pushing large layersno such file or directory for SSL cert in Dockerlive/ breaks themarchive/ directoryunsupported platform in Docker Swarmdocker buildx build --platform linux/amd64 or build directly on the serverno such host DNS error in Swarmdocker service update --force <service>A self-hosted Docker registry is straightforward to set up and pays off quickly when you're managing multiple private images. The key gotchas are:
live/ and archive/ in Dockerclient_max_body_size 0 is non-negotiable for large image pushesWith this setup, you get a fast, private registry with no rate limits — ready for use with Docker Compose, Docker Swarm, or any CI/CD pipeline.