A private Docker registry lets you store and distribute your own container images without pushing them to Docker Hub or another public service. The official registry image (the CNCF Distribution project) is a small, stateless server that stores image layers on disk or in object storage. In this tutorial you will run the registry with Docker Compose on Ubuntu 24.04, protect it with password authentication, publish it over HTTPS through Nginx with a Let's Encrypt certificate, push and pull an image from another machine, and reclaim disk space with garbage collection.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with enough disk for your images (start with 20 GB free).
- A non-root user with
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- A domain name with an A record pointing to the server's public IP. This guide uses
registry.your_domain; replace it everywhere with your own hostname. - Ports 80 and 443 reachable from the internet so Let's Encrypt can validate the domain.
- A second machine with Docker installed (your laptop or a CI runner) to test pushing and pulling.
Step 1 - Creating the directory layout
Keep everything the registry needs in one directory: the Compose file, the image data and the password file.
sudo mkdir -p /opt/registry/data /opt/registry/auth
cd /opt/registry
The registry container writes image layers to /opt/registry/data, which is the directory to back up and to monitor for disk usage.
Step 2 - Creating the password file
The registry supports basic authentication with an Apache htpasswd file, and it only accepts bcrypt hashes. Install the htpasswd tool:
sudo apt update
sudo apt install apache2-utils
Create the file with a first user. The -B flag selects bcrypt and -c creates the file; replace your_user with the username you want and type a strong password when prompted:
sudo htpasswd -Bc /opt/registry/auth/htpasswd your_user
New password:
Re-type new password:
Adding password for user your_user
To add more users later, run the same command without -c, which would otherwise overwrite the file:
sudo htpasswd -B /opt/registry/auth/htpasswd ci_user
Step 3 - Running the registry with Docker Compose
Create the Compose file:
sudo nano /opt/registry/compose.yaml
Paste the following configuration:
services:
registry:
image: registry:3
restart: unless-stopped
ports:
- "127.0.0.1:5000:5000"
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Registry
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_STORAGE_DELETE_ENABLED: "true"
OTEL_TRACES_EXPORTER: none
volumes:
- ./data:/var/lib/registry
- ./auth:/auth:ro
What each setting does:
127.0.0.1:5000:5000publishes the registry only on the loopback interface. Nginx will be the only public entry point, and Docker's published ports cannot bypass your firewall this way.- The
REGISTRY_AUTH_*variables enable basic authentication against the file you created. Any key of the registry's YAML configuration can be set as an environment variable with theREGISTRY_prefix and underscores between levels. REGISTRY_STORAGE_DELETE_ENABLEDallows deleting manifests through the API, which you need for cleanup in Step 7.OTEL_TRACES_EXPORTER: nonestops the registry from trying to send OpenTelemetry traces to a collector that does not exist.
Start the registry:
sudo docker compose up -d
Check that it answers locally. Without credentials the API returns 401 Unauthorized, which proves authentication is active:
curl -i http://127.0.0.1:5000/v2/
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Basic realm="Registry"
...
With valid credentials the same request returns 200 OK and an empty JSON object:
curl -u your_user http://127.0.0.1:5000/v2/
{}
Step 4 - Installing Nginx and opening the firewall
Docker refuses to push to a registry over plain HTTP unless every client is reconfigured to allow it, so the registry needs a valid TLS certificate. Nginx will terminate HTTPS and forward requests to the registry on 127.0.0.1:5000.
Install Nginx and Certbot with its Nginx plugin:
sudo apt install nginx certbot python3-certbot-nginx
Allow HTTP and HTTPS through UFW (make sure SSH is allowed too before enabling UFW):
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
Step 5 - Configuring Nginx as a reverse proxy
Create a server block for the registry:
sudo nano /etc/nginx/sites-available/registry
Add the following, replacing registry.your_domain with your hostname:
server {
listen 80;
listen [::]:80;
server_name registry.your_domain;
# Image layers can be several GB; disable the upload size limit
client_max_body_size 0;
chunked_transfer_encoding on;
location /v2/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $http_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;
proxy_request_buffering off;
}
}
client_max_body_size 0 is essential: the Nginx default of 1 MB makes every layer push fail with 413 Request Entity Too Large. proxy_request_buffering off streams uploads to the registry instead of spooling them to disk first.
Enable the site, test the configuration and reload Nginx:
sudo ln -s /etc/nginx/sites-available/registry /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Step 6 - Obtaining a Let's Encrypt certificate
Run Certbot with the Nginx plugin. It validates the domain, installs the certificate in the server block and adds an HTTP to HTTPS redirect:
sudo certbot --nginx -d registry.your_domain
Follow the prompts (email address and terms of service). When it finishes you should see:
Successfully deployed certificate for registry.your_domain to /etc/nginx/sites-enabled/registry
Congratulations! You have successfully enabled HTTPS on https://registry.your_domain
The certbot package installs a systemd timer that renews the certificate automatically. Confirm renewal works with a dry run:
sudo certbot renew --dry-run
Test the registry through HTTPS from any machine:
curl -u your_user https://registry.your_domain/v2/
{}
Step 7 - Pushing and pulling images
On your second machine (laptop or CI runner), log in to the registry. Docker stores the credentials in ~/.docker/config.json, so use a credential helper on shared machines:
docker login registry.your_domain
Username: your_user
Password:
Login Succeeded
Images are pushed to a registry by tagging them with the registry hostname as a prefix. Pull a small public image, tag it for your registry and push it:
docker pull alpine:latest
docker tag alpine:latest registry.your_domain/tools/alpine:latest
docker push registry.your_domain/tools/alpine:latest
The push refers to repository [registry.your_domain/tools/alpine]
418dccb7d85a: Pushed
latest: digest: sha256:... size: 528
List the repositories stored in the registry:
curl -u your_user https://registry.your_domain/v2/_catalog
{"repositories":["tools/alpine"]}
Finally, remove the local copy and pull it back from your registry to confirm the round trip:
docker image rm registry.your_domain/tools/alpine:latest
docker pull registry.your_domain/tools/alpine:latest
The pull completes with Status: Downloaded newer image for registry.your_domain/tools/alpine:latest.
Step 8 - Deleting images and running garbage collection
Deleting an image from a registry is a two-part process. First you delete the manifest through the API, which removes the reference. The layers stay on disk until you run garbage collection, which removes blobs that no manifest references anymore.
To delete a tag, get the digest of its manifest. The Accept headers make the registry return the digest of the same manifest type that was pushed (multi-platform index, OCI or Docker format):
curl -sI -u your_user \
-H "Accept: application/vnd.oci.image.index.v1+json" \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
https://registry.your_domain/v2/tools/alpine/manifests/latest | grep -i docker-content-digest
docker-content-digest: sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
Delete the manifest using that digest:
curl -u your_user -X DELETE \
https://registry.your_domain/v2/tools/alpine/manifests/sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
The registry answers 202 Accepted with an empty body. Now run garbage collection on the server. Garbage collection must not run while clients are pushing, or it can delete layers of an upload in progress, so stop the registry first, run the collector in a one-off container with the same volumes, and start it again:
cd /opt/registry
sudo docker compose stop registry
sudo docker compose run --rm registry garbage-collect --delete-untagged /etc/distribution/config.yml
sudo docker compose start registry
The collector lists what it marks and deletes, and du -sh /opt/registry/data shows the space you reclaimed. --delete-untagged also removes manifests that no longer have any tag, which is what accumulates when you push the same tag (for example latest) repeatedly.
Scheduling garbage collection
To run the cleanup weekly during a quiet period, create a small script:
sudo nano /usr/local/bin/registry-gc
#!/usr/bin/env bash
set -euo pipefail
cd /opt/registry
docker compose stop registry
trap 'docker compose start registry' EXIT
docker compose run --rm registry garbage-collect --delete-untagged /etc/distribution/config.yml
The trap guarantees the registry starts again even if the collector fails. Make it executable and test it once:
sudo chmod 755 /usr/local/bin/registry-gc
sudo /usr/local/bin/registry-gc
Then schedule it with root's crontab for every Sunday at 04:00:
sudo crontab -e
0 4 * * 0 /usr/local/bin/registry-gc >> /var/log/registry-gc.log 2>&1
Troubleshooting
413 Request Entity Too Largeduring push:client_max_body_size 0;is missing from the Nginx server block that Certbot modified. Check/etc/nginx/sites-enabled/registryand reload Nginx.unauthorized: authentication required: rundocker login registry.your_domainagain, and confirm the user exists withsudo cat /opt/registry/auth/htpasswd. Hashes must start with$2y$(bcrypt).http: server gave HTTP response to HTTPS client: the client is talking to port 5000 directly. Always useregistry.your_domainwithout a port so traffic goes through Nginx on 443.- Registry fails to start: check
sudo docker compose logs registryin/opt/registry. A typo in an environment variable name is silently ignored, but a wrong path for the htpasswd file stops the container.
Conclusion
You now have a private Docker registry served over HTTPS, protected with password authentication, with a working push and pull flow and scheduled garbage collection. Next steps: add a dedicated ci_user and store its password in your CI system's secret store, back up /opt/registry/data and /opt/registry/auth, and point your Docker Swarm or Kubernetes nodes at the registry with docker login or an image pull secret.
