Running Your Own Container Registry
Docker Hub rate limits, images you build yourself, and not wanting a deployment to depend on someone else’s uptime. Those are the reasons.
The reference registry
One binary, and adequate for a lot of cases.
services:
registry:
image: registry:3
environment:
REGISTRY_STORAGE_DELETE_ENABLED: "true"
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "Registry"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
- ./data:/var/lib/registry
- ./auth:/auth:ro
ports:
- "127.0.0.1:5000:5000"
restart: unless-stopped
mkdir auth
docker run --rm --entrypoint htpasswd httpd:2 -Bbn ci-user 'a-real-password' > auth/htpasswd
REGISTRY_STORAGE_DELETE_ENABLED is off by default, and without it you cannot delete anything and the disk fills forever.
bcrypt (-B) is required. The registry rejects other htpasswd formats.
Put it behind a reverse proxy with TLS. Docker refuses plain HTTP registries unless every client is configured to allow it, and maintaining that exception across machines is more work than a certificate.
registry.example.com {
reverse_proxy registry:5000
request_body {
max_size 2GB
}
}
That max_size matters. Large image layers fail to push with a confusingly generic error if the proxy caps the body size, and the default cap on several proxies is far below layer sizes.
docker login registry.example.com
docker tag myapp:1.2.3 registry.example.com/myapp:1.2.3
docker push registry.example.com/myapp:1.2.3
Garbage collection
The part people discover when the disk fills.
Deleting a tag does not free space. It removes a reference. The blobs stay until garbage collection runs.
# delete a manifest by digest
curl -u user:pass -X DELETE \
https://registry.example.com/v2/myapp/manifests/sha256:abc123...
# then actually reclaim
docker compose exec registry \
bin/registry garbage-collect /etc/distribution/config.yml
Run it with the registry read-only. Garbage collection can delete a blob that a concurrent push is still uploading, which corrupts the image being pushed.
environment:
REGISTRY_STORAGE_MAINTENANCE_READONLY: '{"enabled":true}'
Set it, restart, collect, unset, restart. That is awkward enough that it is a genuine argument for Harbor, which handles retention properly.
A systemd timer is the right way to schedule it, and the timer builder generates the units.
Pull-through cache
The configuration most people should run, and the one most people do not know about.
services:
dockerhub-cache:
image: registry:3
environment:
REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io
REGISTRY_PROXY_USERNAME: your-dockerhub-user
REGISTRY_PROXY_PASSWORD: your-dockerhub-token
volumes:
- ./cache:/var/lib/registry
ports:
- "127.0.0.1:5001:5000"
// /etc/docker/daemon.json
{
"registry-mirrors": ["https://cache.example.com"]
}
The first pull of an image fetches upstream and keeps a copy; every subsequent pull is local. On a CI runner pulling the same base images hundreds of times a day, that is a large bandwidth saving and a meaningful speedup, and it largely sidesteps rate limiting.
Authenticating the cache to Docker Hub raises the upstream limit that applies to it.
A separate cache per upstream is needed, since a registry in proxy mode mirrors exactly one.
Harbor
When the reference registry stops being enough.
curl -LO https://github.com/goharbor/harbor/releases/download/v2.13.0/harbor-offline-installer-v2.13.0.tgz
tar xzf harbor-offline-installer-*.tgz
cd harbor
cp harbor.yml.tmpl harbor.yml
# edit hostname, TLS paths, admin password
sudo ./install.sh --with-trivy
What it adds:
A web interface and real user management, with projects, roles, and robot accounts for CI rather than sharing one credential.
Vulnerability scanning through Trivy, on push, with policies that can block pulls of images above a severity threshold.
Retention policies that actually work, expressed as rules rather than a manual garbage collection dance.
Replication between registries, for multi-site or for promoting images from staging to production.
Image signing through cosign integration.
The cost is real: PostgreSQL, Redis, several services, and meaningfully more to operate and upgrade. Harbor earns it for a team; for one person pushing a few images it is a lot of machinery.
Signing
Worth doing for anything deployed automatically.
# keyless, using an OIDC identity
cosign sign registry.example.com/myapp:1.2.3
cosign verify \
--certificate-identity-regexp '.*@example\.com' \
--certificate-oidc-issuer https://accounts.google.com \
registry.example.com/myapp:1.2.3
Keyless signing removed the reason most people never signed images, which was key management. The signature binds to an identity verified by an OIDC provider, with the record in a transparency log.
The point is that a compromised registry cannot substitute an image. Verification at deploy time fails rather than silently running something else. Our container security guide covers pinning digests, which addresses the same threat from the other direction.
Backups
The registry is a filesystem tree of blobs and metadata, so it backs up like anything else.
restic backup /srv/registry/data
Harbor additionally needs its PostgreSQL database, and the two must be consistent with each other. The same requirement as Nextcloud: database and files together, or you get a catalogue pointing at nothing.
Our restic and Borg comparison covers the tooling.
Worth asking whether you need backups at all. If every image is rebuilt from a Dockerfile in Git, the registry is a cache and losing it costs a rebuild. That is a legitimate position and it is worth deciding deliberately rather than discovering it during an outage.
Storage
environment:
REGISTRY_STORAGE: s3
REGISTRY_STORAGE_S3_REGION: us-east-1
REGISTRY_STORAGE_S3_BUCKET: my-registry
REGISTRY_STORAGE_S3_ACCESSKEY: ...
REGISTRY_STORAGE_S3_SECRETKEY: ...
S3 or MinIO backing means the registry is stateless and storage grows without you provisioning disks. For a registry accumulating image layers indefinitely, that is the shape that scales.
Frequently Asked Questions
Why run a private container registry?
Docker Hub enforces pull rate limits that CI pipelines hit easily, images you build yourself need somewhere to live, and a local registry removes an external dependency from deployments. A pull-through cache also cuts bandwidth and speeds up builds considerably.
What is the difference between the reference registry and Harbor?
The reference registry is a single Go binary that stores and serves images with no interface and minimal access control. Harbor adds a web UI, user management, vulnerability scanning, image signing, replication, and retention policies, at the cost of running a database and several services.
How do I reclaim space from deleted images?
Deleting a tag removes the reference, and the underlying blobs stay until garbage collection runs. The reference registry needs garbage-collect run explicitly, ideally with the registry read-only during the run, or it can delete blobs that a concurrent push is still uploading.
What is a pull-through cache?
A registry configured as a mirror of an upstream one. The first pull of an image fetches it from upstream and stores a copy, and subsequent pulls are served locally. It reduces bandwidth, speeds up CI, and largely sidesteps upstream rate limiting.
Does a private registry need TLS?
Yes in practice. Docker refuses plain HTTP registries unless each client is configured to treat it as insecure, which is a change you would have to make on every machine. A certificate from Let’s Encrypt or an internal CA is less work than maintaining that exception.
Should I sign my container images?
For anything deployed automatically, yes. Signing with cosign and verifying at deploy time means a compromised registry cannot substitute an image, because the signature will not match. Keyless signing through an OIDC identity removes the key management burden that stopped people doing this before.