Skip to main content

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 DELETE API or use a CLI like regctl. Deletion is disabled by default; set REGISTRY_STORAGE_DELETE_ENABLED: "true" in the environment.
  • Garbage collection: run docker exec <registry-container> bin/registry garbage-collect /etc/docker/registry/config.yml to 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.

Deploy your registry