How To Set Up a Private Docker Registry with Docker Compose
Tricknowtech Team 14 min read
Goal
By the end, you will have a private Docker registry running via Docker Compose with images persisted to a named volume, access restricted by htpasswd Basic Authentication, and TLS handled by an Nginx + Certbot reverse proxy — verified by pushing and pulling a test image.
Prerequisites
An Ubuntu 22.04 LTS server with a non-root sudo user, reachable over SSH, and ufw enabled
Docker Engine and the Docker Compose plugin installed on the server
A registered domain name with an A record pointing at the server's public IP
Nginx already configured as a reverse proxy with a valid TLS certificate from Certbot for that domain
Basic familiarity with docker pull/tag/push
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
The Docker Hub is convenient for public images, but most teams eventually need a private place to store images that never leave their own infrastructure — internal build artifacts, proprietary application images, or base images customized for an organization. The official `registry:2` image implements the Docker Registry HTTP API v2 and is enough to run a fully private registry yourself, with Docker Compose managing the container and a reverse proxy handling TLS.
In this tutorial, you will run the `registry:2` image with Docker Compose, persist pushed images to a named Docker volume so they survive container restarts, restrict access with HTTP Basic Authentication backed by an `htpasswd` file, and put the registry behind an Nginx reverse proxy secured with a Let's Encrypt certificate from Certbot rather than exposing it directly to the internet. You will finish by pushing and pulling a test image to confirm the whole chain works.
Prerequisites
An Ubuntu 22.04 LTS server, set up per an initial server setup guide: a non-root user with `sudo` privileges, reachable over SSH, with a basic firewall (`ufw`) enabled.
Docker Engine and the Docker Compose plugin installed on that server, so that `docker compose version` runs without error.
A registered domain name — this tutorial uses `registry.example.com` — with an A record pointing at the server's public IP address.
Nginx installed and already configured as a reverse proxy for that domain, with a valid TLS certificate obtained via Certbot (for example, by running `sudo certbot --nginx -d registry.example.com`). This tutorial assumes that setup exists and only adds the registry-specific location block; it does not re-explain installing Nginx or Certbot from scratch.
Familiarity with basic Docker client commands: `docker pull`, `docker tag`, and `docker push`.
Step 1 — Creating the Project Directory and a Docker Compose File
Start by creating a dedicated directory for the registry's Compose project. Keeping it in its own directory matters because Docker Compose derives default volume and network names from the directory name, and it gives you a single place to keep the compose file alongside the authentication files you'll add later.
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
mkdir -p ~/docker-registry/auth
cd ~/docker-registry
Create `docker-compose.yml` with a minimal service definition first — you will layer in authentication in a later step. Note that the container's port 5000 is published only to `127.0.0.1`, not to all interfaces: the registry should never be reachable directly, only through the TLS-terminating reverse proxy you'll configure in Step 5.
“Binding to 127.0.0.1:5000 means the registry is unreachable from outside the server even if the firewall were misconfigured — the only path in is through Nginx on ports 80/443. Do not change this to 0.0.0.0 or a bare 5000:5000 mapping.”
Step 2 — Understanding the Persistent Storage Volume
The `registry-data` entry under the top-level `volumes:` key is a named Docker volume, and the `registry:2` image writes all pushed layers and manifests under `/var/lib/registry` by default — the mount in Step 1 points that path at the named volume instead of the container's writable layer. This means running `docker compose down` and `docker compose up -d` again, or even recreating the container after a `registry:2` image update, does not lose any pushed images; only `docker compose down -v` (which explicitly removes volumes) or manually deleting the volume would.
You can inspect where Docker actually stores this volume's data on disk once the project is running. Compose prefixes volume names with the project name (normally your directory name), so for this project it resolves to `docker-registry_registry-data`.
bash
docker volume ls | grep registry-data
docker volume inspect docker-registry_registry-data
Step 3 — Creating HTTP Basic Auth Credentials
The registry's built-in `htpasswd` auth backend checks incoming requests against a bcrypt-hashed credentials file. Install `apache2-utils`, which provides the `htpasswd` utility, and generate the file inside the `auth/` directory you created earlier.
bash
sudo apt update
sudo apt install -y apache2-utils
Create the file with your first user. The `-B` flag forces bcrypt hashing, which is the only algorithm the registry's htpasswd auth backend accepts, `-c` creates a new file, and `-b` lets you supply the password on the command line instead of being prompted.
“If you'd rather not install apache2-utils on the host, generate the same file with a throwaway container: `docker run --rm httpd:2 htpasswd -Bbn registryuser 'ChangeThisPassword123' > auth/htpasswd`. The `-n` flag prints the hash to stdout instead of writing a file, which you then redirect yourself.”
Step 4 — Wiring Authentication into the Compose File and Starting the Registry
Update `docker-compose.yml` to mount the `auth/` directory into the container and tell the registry to require it. `REGISTRY_AUTH_HTPASSWD_PATH` must match the file's path inside the container, not on the host.
Start the service in the background and confirm it's running and healthy from the loopback interface.
bash
docker compose up -d
docker compose ps
An unauthenticated request should now be rejected with a 401, and the same request with valid credentials should return an empty catalog, since nothing has been pushed yet.
Step 5 — Proxying the Registry Through Nginx with TLS
With Nginx and a Certbot-issued certificate for `registry.example.com` already in place per the prerequisites, add a `location /v2/` block to that site's server block that proxies to the registry container on `127.0.0.1:5000`. The Docker Registry API lives entirely under the `/v2/` path prefix, so scoping the proxy to it is sufficient.
nginx
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name registry.example.com;
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
# Docker pushes can involve large layers; do not let Nginx cap the body size.
client_max_body_size 0;
location /v2/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 900;
}
}
Test the configuration and reload Nginx to apply it.
bash
sudo nginx -t
sudo systemctl reload nginx
“Because the certificate comes from a publicly trusted CA (Let's Encrypt via Certbot), the Docker client trusts it automatically — unlike a self-signed certificate, you do not need to add `registry.example.com` to any daemon.json `insecure-registries` list or distribute a CA bundle to every machine that will use the registry.”
Step 6 — Logging In to the Registry from the Docker Client
From any machine with the Docker client installed — the server itself, or a separate workstation — authenticate against the domain, not the internal `127.0.0.1:5000` address, since that's the endpoint TLS and auth are actually served on.
bash
docker login registry.example.com
Enter the username and password created in Step 3. A successful login writes an entry to `~/.docker/config.json` on the client, which Docker reuses for subsequent pushes and pulls against that host.
“By default, `~/.docker/config.json` stores that credential in plaintext base64, not encrypted. For anything beyond a quick test, configure a Docker credential helper (`docker-credential-helpers`) appropriate to your OS so credentials are kept in a system keychain instead.”
Step 7 — Pushing and Pulling a Test Image to Verify
Pull a small public image, tag it for your private registry, and push it. Docker tags must be prefixed with the registry's hostname for the client to know where to send them.
bash
docker pull hello-world
docker tag hello-world registry.example.com/hello-world:test
docker push registry.example.com/hello-world:test
Confirm it landed by querying the catalog API over HTTPS through Nginx, then remove the local copy and pull it back down from the registry to prove the round trip works end to end.
If the pull succeeds after the local image was removed, the full chain — Docker client, TLS termination, Basic Auth, and persistent storage — is working correctly.
Step 8 — (Optional) Running Garbage Collection
Deleting a tag through the API only removes its manifest reference; the underlying image layer blobs stay on disk until garbage collection runs. Deletion is disabled by default, so if you want to reclaim space by removing old tags, add `REGISTRY_STORAGE_DELETE_ENABLED: "true"` to the `environment:` block in `docker-compose.yml` and run `docker compose up -d` to apply it.
After deleting the manifests you no longer need (via the API or by overwriting a tag with `docker push`), run the registry's built-in garbage collector inside the running container. Use `--dry-run` first to see what would be removed without touching anything.
“Stop pushes to the registry while garbage collection runs. Concurrent pushes during a collection pass can, in rare cases, race with blob deletion and corrupt the affected image.”
Conclusion
You now have a private Docker registry running as a Docker Compose service, storing pushed images in a named volume that survives container restarts and recreation, gated by HTTP Basic Authentication backed by a bcrypt `htpasswd` file, and reachable only through an Nginx reverse proxy terminating TLS with a Certbot-issued certificate. You verified the setup by logging in with the Docker client and pushing and pulling a test image over that path. From here, you can add more `htpasswd` users for teammates, point CI pipelines at `registry.example.com` for automated image builds, or layer additional access control in front of Nginx if you need per-repository permissions beyond what registry-wide Basic Auth provides.