Docker & DevOps

Run a private Docker registry on your VPS

Host your own private Docker registry with the official registry image. Add TLS through a reverse proxy and basic auth, push and pull images, run garbage collection and size the storage.

AdvancedLinux server5 min read

A private registry lets your servers and CI pipelines push and pull images without relying on a third-party service or its pull limits. The official registry image from the Docker Distribution project does the job in one container. This guide runs it behind an HTTPS reverse proxy with password protection, then shows how to push, pull and clean up.

Before you start

  • A Linux VPS with Docker Engine and the Compose plugin. See Install Docker and Docker Compose.
  • A domain or subdomain, such as registry.example.com, with an A record pointing at the VPS.
  • Ports 80 and 443 open in your firewall for the reverse proxy.
  • Enough disk for your images. Plan for several times the size of the images you push, because every tag you keep stores its own layers.

Note: This guide uses the registry:2 tag, which is the long-established major version. A newer major version of the image also exists. Check the image's page on Docker Hub and the Distribution project's release notes before you choose, because configuration paths can differ between major versions.

Create the folders and a password file

Bash
sudo mkdir -p /opt/registry/{data,auth}
cd /opt/registry

Basic auth needs an htpasswd file with bcrypt hashes. Generate it with the httpd image so you do not need to install Apache tools:

Bash
docker run --rm --entrypoint htpasswd httpd:2 -Bbn deploy 'a-long-random-password' | sudo tee auth/htpasswd

Replace deploy and the password with your own. Add more users by running the command again with tee -a.

Write the Compose file

Create /opt/registry/compose.yaml. This example uses Caddy as the reverse proxy because it obtains a Let's Encrypt certificate automatically:

YAML
services:
  registry:
    image: registry:2
    restart: unless-stopped
    environment:
      REGISTRY_AUTH: htpasswd
      REGISTRY_AUTH_HTPASSWD_REALM: Registry
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
      REGISTRY_STORAGE_DELETE_ENABLED: "true"
    volumes:
      - ./data:/var/lib/registry
      - ./auth:/auth:ro

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

The registry has no ports: entry. Only Caddy can reach it, over the Compose network.

Create /opt/registry/Caddyfile:

registry.example.com {
    request_body {
        max_size 5GB
    }
    reverse_proxy registry:5000
}

The body size limit allows large layers. Caddy passes the authentication headers through unchanged, so the registry handles logins itself.

If you already run Nginx as a reverse proxy, point it at the registry instead. Set client_max_body_size 0; so large layer uploads are not rejected, and pass the Host and X-Forwarded-Proto headers.

Start the registry

Bash
cd /opt/registry
docker compose up -d
docker compose logs -f caddy

Wait until Caddy logs that it obtained a certificate, then press Ctrl + C.

Push and pull images

Log in from any machine with Docker:

Bash
docker login registry.example.com

Tag an image with the registry's address and push it:

Bash
docker tag myapp:1.0 registry.example.com/myapp:1.0
docker push registry.example.com/myapp:1.0

Pull it on another server after logging in there:

Bash
docker pull registry.example.com/myapp:1.0

In Compose files, use the full name as the image: value.

Tip: Create a separate user per server or CI system in the htpasswd file. You can then remove one machine's access without changing every other password.

Delete images and run garbage collection

Deleting a tag through the registry API only removes the reference. The layers stay on disk until you run garbage collection.

List repositories and tags:

Bash
curl -u deploy https://registry.example.com/v2/_catalog
curl -u deploy https://registry.example.com/v2/myapp/tags/list

To delete a tag, find its manifest digest and send a DELETE request:

Bash
curl -u deploy -sI \
  -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
  https://registry.example.com/v2/myapp/manifests/1.0 | grep -i docker-content-digest
curl -u deploy -X DELETE https://registry.example.com/v2/myapp/manifests/sha256:DIGEST

Images built with BuildKit may use the OCI manifest format. If the first command returns no digest, repeat it with Accept: application/vnd.oci.image.index.v1+json.

Then reclaim the space. Do a dry run first:

Warning: Do not push images while garbage collection runs. Stop the registry, or make sure no CI job is pushing, before you run it.

Bash
docker compose stop registry
docker compose run --rm registry garbage-collect --dry-run /etc/docker/registry/config.yml
docker compose run --rm registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
docker compose start registry

Size and back up the storage

  • Check usage with sudo du -sh /opt/registry/data.
  • Keep only the tags you might roll back to. A CI job that pushes a tag per commit can fill a disk quickly.
  • Back up /opt/registry/data and auth with your normal file backups. For large registries, the registry can also store data in S3-compatible object storage; see the Distribution project's storage driver documentation.

Check it works

The API base answers with 401 without credentials and 200 with them:

Bash
curl -s -o /dev/null -w '%{http_code}\n' https://registry.example.com/v2/
curl -s -o /dev/null -w '%{http_code}\n' -u deploy https://registry.example.com/v2/

Then push a small test image and pull it back from a second machine.

Troubleshooting

  • 413 Request Entity Too Large while pushing: the reverse proxy limits body size. Raise max_size in Caddy or set client_max_body_size 0; in Nginx.
  • no basic auth credentials or unauthorized: run docker login registry.example.com again on that machine, and check the htpasswd file was created with -B (bcrypt).
  • http: server gave HTTP response to HTTPS client: you are reaching the registry directly on port 5000. Always use the HTTPS domain through the proxy.
  • Disk did not shrink after deleting tags: garbage collection has not run, or REGISTRY_STORAGE_DELETE_ENABLED was not set when you deleted.

Next steps

  • Prefer a hosted registry? See Push images to Docker Hub or GitHub Container Registry.
  • Build and push from CI: GitHub Actions: build and deploy Docker images.
  • Learn more about the proxy used here: Caddy reverse proxy for Docker apps.
  • Get disk space for your registry on a Linux VPS.
Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.