How to Run a Private Docker Image Registry
Table of contents
- Key takeaways
- What is a private image registry for?
- The registry docker-compose.yml
- How do you add basic authentication with htpasswd?
- Publishing the registry over HTTPS behind a reverse proxy
- How to push and pull images
- Web-UI alternatives: Harbor and Zot
- Frequently asked questions
- Why does my docker push fail with a 401 or authentication error?
- Do I need HTTPS for a private registry?
- How do I delete images to reclaim space?
- Conclusion
- Sources
A private Docker registry is your own image store: the official registry image listens on port 5000, keeps layers in a volume and, with htpasswd authentication in bcrypt format and HTTPS behind a reverse proxy, lets you push and pull images without depending on Docker Hub or its pull-rate limits.
Every docker pull of a public image goes through Docker Hub, with its pull-rate limits and its dependency on a third party. A private registry flips that around: you run your own image store, control who pushes and who pulls, and serve the layers from your own server. In this guide you will see how to stand up the official registry image with Docker Compose, protect it with htpasswd authentication, publish it over HTTPS behind a reverse proxy and use the push and pull commands, plus when to graduate to Harbor or Zot. The same guide is available in Spanish.
Key takeaways
- The official
registryimage implements the OCI Distribution specification and listens on port 5000; mount a volume at/var/lib/registryso the layers persist. registry:3.0.0was the first stable v3 release sincev2.8.3: it removed theossandswiftstorage drivers and moved configuration to/etc/distribution/config.yml. The latest published is v3.1.1 (1 May 2026).- The registry only accepts htpasswd credentials in bcrypt format: if you generate the file without the
-Boption, every login fails. - Authentication requires encryption: you cannot use htpasswd over plain HTTP. In production, terminate TLS at a reverse proxy such as Traefik with Let’s Encrypt.
- It is configured through environment variables with the
REGISTRY_prefix (REGISTRY_AUTH,REGISTRY_AUTH_HTPASSWD_PATH,REGISTRY_STORAGE_DELETE_ENABLED), without touching any configuration file. - When you need a web UI, role-based access control, vulnerability scanning and image signing, move to Harbor, a CNCF Graduated project.
What is a private image registry for?
A registry is a service that stores and distributes container images. Docker Hub is the default public registry, but it is not the only one: you can run your own inside your network. A private registry has three strong reasons.
The first is independence: you host your internal images (your application image, your custom base images) without pushing them to a public service or hitting Docker Hub’s anonymous pull limits. The second is speed: on a local network, a pull from your own registry travels over the LAN rather than the internet, which speeds up deployments and continuous-integration pipelines. The third is control: you decide who authenticates, what is stored and for how long.
The base building block is the official registry image, maintained by the CNCF distribution project, which implements the OCI Distribution specification. It is the same engine that larger registries such as Harbor build on. Before going further you need Docker installed; if you do not have it yet, review how to install Docker on Ubuntu 24.04.
The registry docker-compose.yml
Let us start with the minimum that works: a registry that listens on port 5000 and keeps images in a named volume. Always pin a specific image version; never use :latest in production, because a silent update can change behaviour without warning.
services:
registry:
image: registry:2.8.3
restart: unless-stopped
ports:
- "5000:5000"
environment:
REGISTRY_STORAGE_DELETE_ENABLED: "true"
volumes:
- registry_data:/var/lib/registry
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5000/v2/"]
interval: 30s
timeout: 5s
retries: 3
volumes:
registry_data:
Bring it up and check that the v2 API responds, which is the endpoint docker push and docker pull use:
docker compose up -d
curl http://localhost:5000/v2/
A {} response with a 200 status confirms the registry is healthy. The registry_data volume keeps the layers under /var/lib/registry, so it survives container restarts and recreations. I used registry:2.8.3 as the widely deployed stable v2 line; if you prefer the newer line, change the tag to registry:3.1.1 and note that v3 moved its configuration to /etc/distribution/config.yml and dropped the oss and swift drivers. The /var/lib/registry volume works for both.
This registry has no authentication: anyone with network access can push and pull. It is fine for a trusted network, but for anything else you must lock it down.
How do you add basic authentication with htpasswd?
The registry has built-in basic authentication by reading an htpasswd file. There is one detail that trips many people up: it only accepts the bcrypt format. If you generate the credentials without the -B option, the registry rejects them and every login fails. Generate the file with the Apache image itself, without installing anything on the host:
mkdir -p auth
docker run --rm --entrypoint htpasswd httpd:2 -Bbn myuser mypassword > auth/htpasswd
Now extend the service to load that file and enable authentication through environment variables. Notice that everything is controlled with the REGISTRY_ prefix, without editing any config.yml:
services:
registry:
image: registry:2.8.3
restart: unless-stopped
ports:
- "5000:5000"
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "Private registry"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_STORAGE_DELETE_ENABLED: "true"
volumes:
- registry_data:/var/lib/registry
- ./auth:/auth:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5000/v2/"]
interval: 30s
timeout: 5s
retries: 3
volumes:
registry_data:
There is one security rule you cannot skip: basic authentication requires TLS. The distribution documentation states it plainly: you cannot use authentication schemes that send credentials in clear text without first configuring encryption. If you expose the registry directly, you would have to mount a certificate with REGISTRY_HTTP_TLS_CERTIFICATE and REGISTRY_HTTP_TLS_KEY. In practice it is more convenient to let a reverse proxy handle HTTPS, as we will see next. To decide how much memory and CPU to give the service and how to review its logs, lean on the guide to resource limits and logging in Docker.
Publishing the registry over HTTPS behind a reverse proxy
In any serious deployment you do not expose port 5000 to the world: you put a reverse proxy in front that terminates TLS with a Let’s Encrypt certificate and forwards the traffic to the registry over Docker’s internal network. Traefik is the natural choice in this series. The idea is to remove the registry’s ports (stop publishing it directly) and add labels so Traefik routes it:
services:
registry:
image: registry:2.8.3
restart: unless-stopped
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "Private registry"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
- registry_data:/var/lib/registry
- ./auth:/auth:ro
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.registry.rule=Host(`registry.example.com`)"
- "traefik.http.routers.registry.entrypoints=websecure"
- "traefik.http.routers.registry.tls.certresolver=letsencrypt"
- "traefik.http.services.registry.loadbalancer.server.port=5000"
volumes:
registry_data:
networks:
proxy:
external: true
With this setup, Traefik resolves the TLS and the registry only speaks HTTP over the internal proxy network, which never leaves the host. HTTPS at the edge plus htpasswd authentication is the recommended configuration for a homelab or a small team. If you are starting from scratch with the proxy, first follow how to install Traefik with Docker Compose and reuse its external network. And if you ever needed to expose it without TLS just for local testing, you would have to add the host to insecure-registries in /etc/docker/daemon.json, something you should never do in production.
How to push and pull images
With the registry published at registry.example.com, the workflow is four steps. The docker tag command is the key: an image is pushed to your registry only if its name starts with the registry domain.
- Log in to your registry with the htpasswd credentials:
docker login registry.example.com. - Tag the local image with the registry prefix:
docker tag myapp:1.0 registry.example.com/myapp:1.0. - Push the tagged image with
docker push registry.example.com/myapp:1.0. - Pull the image from another machine with
docker pull registry.example.com/myapp:1.0.
To see it all at once, these are the complete commands:
docker login registry.example.com
docker tag myapp:1.0 registry.example.com/myapp:1.0
docker push registry.example.com/myapp:1.0
docker pull registry.example.com/myapp:1.0
The first login stores the credentials in ~/.docker/config.json, so you do not have to repeat it on every push. If you automate deployments, chain your registry with an update tool such as Watchtower to update containers, which can watch your own registry and refresh containers when you publish a new image.
Web-UI alternatives: Harbor and Zot
The registry image is deliberately austere: it ships without a web UI, without role-based users and without vulnerability scanning. As the team grows, two CNCF projects fill that gap without leaving the OCI ecosystem:
- Harbor is an enterprise-grade registry that extends the same distribution engine and adds a web console, role-based access control (RBAC), vulnerability scanning, image signing and cross-registry replication. It has been a CNCF Graduated project since June 2020, the highest maturity the foundation grants. It is the obvious choice if you need governance and auditing.
- Zot is a lightweight, vendor-neutral, OCI-native registry (it adheres purely to the OCI Distribution specification). It includes a web UI, is lighter than Harbor and fits well when you want a UI without the complexity of a full platform.
The rule of thumb: start with registry for personal or small-team use, and migrate to Harbor when you need roles, scanning and signing. Because they all speak OCI, your images are portable between them.
Frequently asked questions
Why does my docker push fail with a 401 or authentication error?
It is almost always the htpasswd file format. The registry only accepts bcrypt: if you generated it without the -B option, it rejects every credential. Regenerate the file with htpasswd -Bbn and restart the container. The second common cause is not having run docker login against the exact registry domain.
Do I need HTTPS for a private registry?
Yes, as soon as you enable authentication: the registry does not accept credentials in clear text. For testing on a trusted network without TLS you can add the host to insecure-registries in /etc/docker/daemon.json, but that is a lab exception, never for production. The right approach is to terminate TLS at a reverse proxy.
How do I delete images to reclaim space?
Enable deletion with REGISTRY_STORAGE_DELETE_ENABLED=true, delete the manifest by its digest through the v2 API and then run the garbage collector with registry garbage-collect /etc/docker/registry/config.yml inside the container. Deleting the manifest only marks the layers; the space is freed during garbage collection.
Conclusion
Running a private Docker registry is surprisingly simple: the registry image on port 5000, a volume for the layers, an htpasswd file in bcrypt and a reverse proxy that provides HTTPS. With that you have your own image store, fast on your network and free of Docker Hub’s limits. When the team or the security requirements grow, Harbor and Zot are waiting with a web UI on top of the same OCI standard. The logical next step is automating container refreshes: review Watchtower to update Docker containers and close the loop between publishing an image and deploying it.
Sources
Source code
Access all the source code for this post on GitHub.
View on GitHub