July 12, 2026 · Docker HQ Team

Managing a Private Docker Registry: A Practical Guide

A private Docker registry is a server you run yourself to store and distribute container images, instead of pushing them to Docker Hub or another public service. The quickest way to stand one up is the official registry:2 image behind a reverse proxy that handles TLS and authentication. This guide covers setting that up properly, plus the day-to-day work most tutorials skip: garbage collection, access control, and keeping the thing from filling your disk at the worst possible time.

Why run your own registry

You want a private registry when you're shipping images you don't want public, when Docker Hub's pull rate limits are throttling your CI, or when you need images to live close to the servers that deploy them. A registry inside your own network pulls faster and doesn't depend on an external provider being up.

You have two broad options. Run the open-source Distribution registry (registry:2) yourself, or use a managed one like GitHub Container Registry, AWS ECR, or Harbor if you want a web UI and built-in scanning. This guide focuses on the self-hosted registry:2 path, because that's the one you actually have to operate.

Standing up your private Docker registry

The registry itself is one container. Don't run it naked on port 5000 though. Without TLS, the Docker daemon refuses to talk to it unless you add it as an insecure registry on every client, which is a bad habit that leaks into production.

Start with a compose file that puts the registry behind persistent storage:

# docker-compose.yml
services:
  registry:
    image: registry:2
    restart: always
    ports:
      - "5000:5000"
    environment:
      REGISTRY_STORAGE_DELETE_ENABLED: "true"
    volumes:
      - ./data:/var/lib/registry
      - ./auth:/auth
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:5000/v2/"]
      interval: 30s

The REGISTRY_STORAGE_DELETE_ENABLED flag matters. Without it, you can't delete image manifests later, and you'll be stuck when the disk fills up. Turn it on now.

Adding authentication

The registry supports HTTP basic auth out of the box. Generate a password file with htpasswd (bcrypt is required, hence -B):

mkdir auth
docker run --rm --entrypoint htpasswd httpd:2 -Bbn deploy 'a-strong-password' > auth/htpasswd

Then wire it into the registry environment:

    environment:
      REGISTRY_AUTH: htpasswd
      REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm"
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
      REGISTRY_STORAGE_DELETE_ENABLED: "true"

Terminating TLS

Basic auth over plain HTTP sends credentials in near-plaintext, so TLS is not optional. The clean approach is a reverse proxy in front. If you already run Caddy, it's about the least effort way to get automatic certificates:

# Caddyfile
registry.example.com {
    reverse_proxy registry:5000
}

Caddy fetches and renews a Let's Encrypt certificate on its own. With Nginx or Traefik you do the same job with more config. The point is that clients talk HTTPS to registry.example.com, and the registry container never exposes port 5000 to the outside world. Drop the ports: mapping from the registry service and let only the proxy be public.

Pushing and pulling images

Once the registry answers on HTTPS, log in from any client:

docker login registry.example.com
# Username: deploy
# Password: ...

Tag an image with the registry hostname as a prefix, then push:

docker tag myapp:latest registry.example.com/myapp:1.4.0
docker push registry.example.com/myapp:1.4.0

The hostname prefix is what tells Docker where to send the image. Pulling on a deploy server is the mirror of that:

docker pull registry.example.com/myapp:1.4.0

Credentials get stored in ~/.docker/config.json. On a CI runner or a shared box, use docker logout when you're done, or scope a dedicated account with a throwaway password.

A quick way to see what's actually stored is the registry HTTP API:

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

The part everyone forgets: storage cleanup

Here's the thing that bites people. Deleting a tag does not free disk space. The registry stores images as content-addressed blobs, and deleting a manifest only removes the reference. The blobs stay until you run garbage collection. Push a new build every commit for a few weeks and you'll watch the disk climb with nothing obvious to delete.

First, delete the manifests you don't want. Get the digest of a tag:

curl -u deploy:pass -I \
  -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
  https://registry.example.com/v2/myapp/manifests/1.3.0

The response has a Docker-Content-Digest header. Delete by that digest:

curl -u deploy:pass -X DELETE \
  https://registry.example.com/v2/myapp/manifests/sha256:abc123...

That removes the manifest but not the underlying layers. Now run garbage collection against the registry to reclaim the space:

docker exec registry-container \
  bin/registry garbage-collect /etc/docker/registry/config.yml

Run GC when the registry is idle or read-only. If a push lands mid-collection, a blob that GC just decided was unreferenced could actually belong to the incoming image. The registry supports read-only mode for exactly this window; set REGISTRY_STORAGE_MAINTENANCE_READONLY to {enabled: true} during the sweep if you can't guarantee quiet time. This is the same discipline you'd apply to your build hosts. If you're cleaning up dangling images on the machines that build these tags, the reasoning in how to prune Docker images without breaking things carries over directly.

Keeping it secure

A registry is a juicy target because it holds your deployable artifacts. A few things that matter more than they look:

  • Separate accounts for push and pull. CI needs push. Deploy servers only need pull. Basic auth doesn't do per-repo scopes, so if you need fine-grained roles, that's the point where Harbor or a managed registry earns its keep.
  • Scan images before they ship. A registry stores whatever you push, including whatever CVEs came in through a base image. Scan on build with docker scout cves or Trivy, and don't promote an image to your stable tags if it's carrying known criticals.
  • Watch the host. The registry container going down means every deploy and every CI push fails at once, usually silently until someone notices a rollout stuck. This is where I lean on Docker HQ to get a push alert the moment the registry container or its host stops responding, instead of finding out from a failed 2am deploy. You can manage the box, read the registry logs, and restart the container from your phone over SSH.

If the registry sits on its own host, lock down the network path too. It should accept connections on 443 from your CI and deploy networks and nothing else. The general rules in Docker networking basics every developer should know apply: publish only what you must, and keep the registry off any bridge that untrusted containers share.

A minimal operational routine

Once it's running, the ongoing work is small but real:

  1. Rotate the htpasswd credentials on a schedule, and immediately when someone leaves.
  2. Delete old tags as part of your release process, not as an emergency when the disk is at 95 percent.
  3. Run garbage collection on a cron, during a quiet window, in read-only mode.
  4. Back up the data/ volume. It's just files, so a tar or a volume snapshot is enough.
  5. Keep an eye on disk and container health. Registry storage growth is steady and predictable, which means it's easy to ignore until it isn't. Continuous container monitoring turns that from a surprise into a graph you glance at.

Frequently asked questions

Do I need TLS for a private Docker registry on my own network?

Yes, unless you enjoy pain. Without TLS, every client must be configured with the registry in its insecure-registries list, and any basic-auth credentials travel effectively in the clear. A reverse proxy with a Let's Encrypt certificate takes ten minutes and removes the whole problem. Reserve insecure mode for a laptop-only experiment you'll throw away.

Why does my registry disk keep growing after I delete images?

Deleting a tag or manifest only removes the reference, not the layer blobs on disk. You have to run bin/registry garbage-collect to reclaim the space, and that only works if you started the registry with REGISTRY_STORAGE_DELETE_ENABLED=true. Run GC during a quiet window so an in-flight push doesn't lose a shared layer.

Can I add role-based access to the open-source registry?

Not really. registry:2 with htpasswd gives you all-or-nothing accounts, so a user who can pull can also push to every repository. If you need per-repository permissions, read/write role splits, or SSO, move to Harbor or a managed registry like ECR or GHCR. For a small team pushing to one or two repos, separate push and pull accounts are usually enough.

How do I mirror Docker Hub to avoid rate limits?

Run registry:2 in pull-through cache mode by setting REGISTRY_PROXY_REMOTEURL to https://registry-1.docker.io. Point your Docker daemons at it with registry-mirrors in /etc/docker/daemon.json. Cached images are served locally on the next pull, which keeps CI off Docker Hub's rate limits. Note that a mirror is read-only, so keep it separate from the registry you push your own images to.

Next step

If you're setting this up today, get TLS and garbage collection right before you point CI at it. Those are the two things that turn into a bad afternoon later. Stand up the registry behind Caddy, push one test image, delete it, and run a garbage-collect pass so you've seen the full lifecycle work once. Then wire up an alert on the registry host so the next failure finds you before your deploys do. Docker HQ's free and paid plans both cover host and container down alerts.