Self-Hosted Docker Registry with Docker Compose (2026)
To self-host a Docker registry, run the official registry:2 image with Docker Compose, mount a volume at /var/lib/registry, and put it behind a reverse proxy that terminates HTTPS. That gives you a private alternative to Docker Hub, GHCR, or ECR: images stay on your own disk, and you push or pull them from anywhere with the docker CLI. This guide covers the minimal setup, TLS, basic authentication, listing images, and cleanup.
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
List the images in your registry
registry:2 has no web UI, so you query the HTTP API directly. List every repository:
curl -u myuser https://registry.example.com/v2/_catalog
List the tags of one repository:
curl -u myuser https://registry.example.com/v2/my-app/tags/list
Both return JSON. Pipe through jq for readable output. If you want a browsable UI on top, joxit/docker-registry-ui runs as a second Compose service pointed at the same registry — but you still need REGISTRY_HTTP_HEADERS CORS settings for it to work from a browser.
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.
Frequently asked questions
How do I self-host a Docker registry?
Run the official 'registry:2' image with Docker Compose, mount a volume at '/var/lib/registry' so images survive restarts, and put a reverse proxy such as Caddy in front to terminate HTTPS. Add an htpasswd file and set REGISTRY_AUTH to lock it down before exposing it.
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.
How do I list the images in a private Docker registry?
There is no UI, so query the API: 'curl -u user https://registry.example.com/v2/_catalog' lists repositories, and '/v2/<name>/tags/list' lists the tags of one repository. Add 'joxit/docker-registry-ui' as a second Compose service if you want a browsable interface.
Why do I get 'http: server gave HTTP response to HTTPS client'?
You are pushing to plain HTTP. The Docker CLI requires TLS for remote registries. Either put the registry behind a reverse proxy with a certificate, or add the host to 'insecure-registries' in /etc/docker/daemon.json for local-only use.
How do I free disk space in a Docker registry?
Deleting a tag does not free blobs. Set REGISTRY_STORAGE_DELETE_ENABLED to 'true', delete the tag via the API or a tool like regctl, then run 'bin/registry garbage-collect /etc/docker/registry/config.yml' inside the container.
Can I use a self-hosted registry 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 multiple 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.