Self-Hosted Docker Registry: Run Your Own Private Registry with Docker Compose
A self-hosted Docker registry is a private alternative to Docker Hub, GHCR, or ECR. You run it yourself, store images on your own disk, and push or pull them from anywhere with the docker CLI. This guide shows the minimal working setup with Docker Compose, then how to add TLS and basic authentication.
When to self-host a Docker registry
Self-hosting makes sense when you want:
- Private images without a paid plan. Docker Hub charges for private repos past the free tier.
- Full control over storage. Images stay on your disk, which is cheaper and keeps them in your jurisdiction.
- Faster internal pulls. A registry on the same network as your CI or production nodes pulls faster than any public registry.
- An air-gapped setup. No outbound dependency on Docker Hub or GHCR.
If none of those apply, Docker Hub or GHCR is simpler and free for public images.
Minimal Docker Compose setup
The official registry:2 image is maintained by the Distribution project (formerly Docker Distribution). It is small, stable, and speaks the standard Docker Registry HTTP API.
services:
registry:
image: registry:2
restart: always
ports:
- "5000:5000"
volumes:
- registry-data:/var/lib/registry
volumes:
registry-data:
Start it:
docker compose up -d
Push a test image from the same host:
docker pull alpine
docker tag alpine localhost:5000/alpine
docker push localhost:5000/alpine
Pull it back:
docker pull localhost:5000/alpine
That is a working registry. But it has no authentication and no TLS, so it only works over localhost or via Docker's "insecure registries" allowlist. Do not expose port 5000 to the internet like this.
Add TLS with a reverse proxy
Docker's CLI refuses to talk to a remote registry over plain HTTP unless you add it to the daemon's insecure registries list. The clean fix is to put the registry behind a reverse proxy that terminates HTTPS.
Here is the same registry behind Caddy, which issues a Let's Encrypt certificate automatically:
services:
registry:
image: registry:2
restart: always
volumes:
- registry-data:/var/lib/registry
# No ports exposed to host — only Caddy talks to it
caddy:
image: caddy:2
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
volumes:
registry-data:
caddy-data:
Caddyfile:
registry.example.com {
reverse_proxy registry:5000
}
Point registry.example.com at your server, bring the stack up, and Caddy fetches a TLS cert on the first request. You can now push from anywhere:
docker tag my-app registry.example.com/my-app
docker push registry.example.com/my-app
Add basic authentication
A public registry behind TLS is still public. Anyone with the URL can read and write. Add HTTP basic auth to lock it down.
Generate an htpasswd file:
docker run --rm --entrypoint htpasswd httpd:2 -Bbn myuser 'my-strong-password' > auth/htpasswd
Mount it into the registry and turn on auth:
services:
registry:
image: registry:2
restart: always
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
- registry-data:/var/lib/registry
- ./auth:/auth:ro
Log in before pushing:
docker login registry.example.com
Storage and cleanup
By default the registry stores images as filesystem blobs in /var/lib/registry. Disk usage grows fast because old image layers are kept until you prune them.
Two maintenance tasks worth knowing:
- Delete an image tag: call the registry's
DELETEAPI or use a CLI likeregctl. Deletion is disabled by default; setREGISTRY_STORAGE_DELETE_ENABLED: "true"in the environment. - Garbage collection: run
docker exec <registry-container> bin/registry garbage-collect /etc/docker/registry/config.ymlto free the disk space that deleted tags were holding.
For real production use, back up the registry-data volume regularly or point the registry at S3/MinIO object storage instead of local disk.
Common issues
http: server gave HTTP response to HTTPS client
You are pushing to plain HTTP without TLS. Either add a reverse proxy with a certificate, or add the registry to /etc/docker/daemon.json under insecure-registries.
unauthorized: authentication required
Credentials are missing or wrong. Run docker login again, and make sure auth/htpasswd was generated with -B (bcrypt). MD5 hashes do not work with the registry.
Disk fills up fast
Tag deletes do not free space on their own. Enable REGISTRY_STORAGE_DELETE_ENABLED and run garbage collection on a schedule.
FAQ
Is registry:2 the same as Harbor or Nexus?
No. registry:2 is the bare protocol server. Harbor, Nexus, and JFrog add a UI, RBAC, vulnerability scans, replication, and retention policies on top of the same protocol. For a small team, registry:2 with Caddy and basic auth is enough.
Can I use this for Helm charts or OCI artifacts?
Yes. Recent registry:2 versions support OCI artifacts, so Helm 3, ORAS, and similar tools can push to the same registry.
How big does the server need to be?
Very small. The registry itself uses tens of megabytes of RAM. Storage is the real cost — plan for several times the size of your largest image to cover versions.
Deploy a self-hosted registry on Hostim
Hostim runs Docker Compose stacks natively on Hetzner infrastructure. Push the Compose file above, point a domain at it, and your registry is live behind HTTPS without server admin.