Skip to main content

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 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.

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.

Deploy your registry