How to Run a Private Docker Registry (Your Own Images, Cached Pulls and a Pi That Builds Nothing)

Your own image registry: push what you build, pull it anywhere, cache Docker Hub — one container behind HTTPS with a login.

Tutorial 102 built an image on the server. The Pi in the shed wants the same image; rebuilding there takes twenty minutes and a cross-compile. A registry is where images live: push once from the build machine, docker pull anywhere. Docker Hub works but is public (or paid for private) and rate-limited; Gitea (45) has a registry built in; and the official registry container is a hundred-line setup that also acts as a cache for Docker Hub, so forty containers updating on Sunday pull each layer from the internet once.

Quick reference

TaskWhat
Folder~/docker/registry/compose.yaml
Addressregistry.example.com behind the proxy, HTTPS, basic auth
Pushdocker tag myapp registry.example.com/myapp:1.0 && docker push registry.example.com/myapp:1.0
Pull (any machine)docker login registry.example.com then docker pull …
Cache Docker Huba second registry in proxy mode + registry-mirrors in daemon.json
Web UIjoxit/docker-registry-ui
Clean upregistry garbage-collect after deleting tags

1. Registry with a login

mkdir -p ~/docker/registry/{data,auth,cache} && cd ~/docker/registry
sudo apt install apache2-utils -y
htpasswd -Bc auth/htpasswd john
nano compose.yaml

services:
registry:
image: registry:2
container_name: registry
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"
volumes:
- ./data:/var/lib/registry
- ./auth:/auth
restart: unless-stopped

registry-ui:
image: joxit/docker-registry-ui:latest
container_name: registry-ui
ports:
- "127.0.0.1:5080:80"
environment:
REGISTRY_URL: https://registry.example.com
REGISTRY_TITLE: Home registry
DELETE_IMAGES: "true"
SINGLE_REGISTRY: "true"
restart: unless-stopped

2. HTTPS through the proxy

Docker refuses plain-HTTP registries unless told otherwise. registry.example.com → 127.0.0.1:5000 with:

client_max_body_size 0;
chunked_transfer_encoding on;
proxy_set_header Docker-Distribution-Api-Version registry/2.0;

and the usual headers (28), plus a certificate — the internal wildcard (147) works for LAN-only use. registry-ui.example.com → 127.0.0.1:5080.

docker compose up -d
docker login registry.example.com

3. Push and pull

On the build machine:

docker build -t weather-bot .
docker tag weather-bot registry.example.com/weather-bot:1.0
docker tag weather-bot registry.example.com/weather-bot:latest
docker push registry.example.com/weather-bot:1.0
docker push registry.example.com/weather-bot:latest

On the Pi (after docker login there too), in its compose file: image: registry.example.com/weather-bot:latest, then docker compose pull && up -d. The Pi builds nothing. (ARM images: build with docker buildx build --platform linux/arm64,linux/amd64 --push -t registry.example.com/weather-bot:1.0 . and one tag serves both.)

The UI at registry-ui.example.com lists repositories, tags and sizes, with delete buttons.

4. A pull-through cache for Docker Hub

Docker Hub limits anonymous pulls per IP. A second registry in proxy mode fetches each layer once and serves it to every machine:

hub-cache:
image: registry:2
container_name: hub-cache
ports:
- "5001:5000"
environment:
REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io
REGISTRY_PROXY_USERNAME: ${HUB_USER}
REGISTRY_PROXY_PASSWORD: ${HUB_TOKEN}
volumes:
- ./cache:/var/lib/registry
restart: unless-stopped

On every Docker host (including this one), /etc/docker/daemon.json:

{ "registry-mirrors": ["http://192.168.1.50:5001"] }

sudo systemctl restart docker. Plain HTTP is accepted for mirrors on the LAN; pulls of nginx, postgres etc. now come from the cache after the first time. Watchtower Sunday (50) stops hammering the internet.

5. Keep it tidy

Deleting a tag in the UI only removes the pointer. Reclaim the space:

docker compose exec registry registry garbage-collect /etc/docker/registry/config.yml

Monthly, from cron. The cache registry can be emptied any time (rm -rf cache/*); it refills.

Gitea instead?

Gitea's built-in package registry stores images next to the repo (git.example.com/john/weather-bot) with Gitea's logins — no extra container, and Gitea Actions can push to it. Use that if you already run Gitea and don't need the Hub cache; this tutorial's registry when you want the cache or a leaner box.

Troubleshooting

"http: server gave HTTP response to HTTPS client". No HTTPS on the registry name; for a LAN-only test add it to insecure-registries in daemon.json — but fix the certificate properly.

"unauthorized: authentication required". docker login on that machine.

Push hangs at "Retrying". Proxy body size or chunked encoding missing.

Pi pulls the wrong architecture. Push a multi-arch image with buildx, or tag per arch.

Wrapping up

One registry container with htpasswd behind HTTPS, a UI beside it, a cache registry for Docker Hub with registry-mirrors on every host. Build once, pull everywhere, Hub rate limits gone, and a monthly garbage-collect. Or let Gitea be the registry if it's already there.

Call us