Every docker push you have ever run talked to a registry. A registry is not
a product — it is an HTTP API that stores blobs (layer tarballs and image
configs) and manifests (small JSON documents saying which blobs a tag
points at). Docker Hub is just one registry, the default one in your config,
and a self-hosted container registry is the same API on a box you own:
one container, one volume, private images that never leave the building.
Everything below ran against throwaway registry:2 containers on a scratch
machine; every command and every block of output is real.
Why self-host a registry at all
Four situations where it earns its keep:
- Private images without a per-seat bill. Build artifacts, internal base images, anything not fit for a public hub.
- No pull rate limits. CI farms and fresh hosts pull as much as they want.
- Data stays home. For NDA work or an air-gapped network, the registry is the deployment channel.
- A local pull-through cache. Point it at Docker Hub upstream and cold nodes on your LAN pull warm and fast.
The minimal run: registry:2 in one command
The official distribution image is the whole registry. A volume for state, a port, done:
docker run -d --name private-registry \
-p 5000:5000 \
-v /srv/registry/data:/var/lib/registry \
registry:2
On my box the logs came up as plain as promised (version 2.8.3):
level=info msg="using inmemory blob descriptor cache" ... version=2.8.3
level=info msg="Starting upload purge in 12m0s" ... version=2.8.3
level=info msg="listening on [::]:5000" ... version=2.8.3
The API answers on /v2/ — the version handshake every client does first:
$ curl http://127.0.0.1:5000/v2/
{}
That empty JSON object means “v2 API, come in”. Everything else is files under
the volume, which I will prove shortly. On a real server I would bind the port
to a private interface rather than 0.0.0.0.
The real docker push → docker pull round trip
A tiny image to push — three lines of Dockerfile on alpine:
FROM alpine:3.20
RUN echo "hello from a private registry" > /message.txt
CMD ["cat", "/message.txt"]
Build it with a full registry address; the registry hostname is part of the image name, not a flag:
$ docker build -t 127.0.0.1:5000/tinydemo:v1 .
#5 [2/2] RUN echo "hello from a private registry" > /message.txt
#5 DONE 0.5s
#6 exporting to image
#6 exporting manifest sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6 0.0s done
#6 naming to 127.0.0.1:5000/tinydemo:v1 done
#6 DONE 0.2s
The docker push uploads only the blobs the registry lacks and answers with
the digest — the sha256 of the manifest, the content address of the image:
$ docker push 127.0.0.1:5000/tinydemo:v1
The push refers to repository [127.0.0.1:5000/tinydemo]
25f1d6b1951a: Pushed
44136fa355b3: Pushed
2bc0aae78406: Pushed
0e8447578a07: Pushed
v1: digest: sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6 size: 855
Now the proof the registry serves it, not my local cache. Delete the image, pull it back, run it:
$ docker rmi 127.0.0.1:5000/tinydemo:v1
Untagged: 127.0.0.1:5000/tinydemo:v1
Deleted: sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6
$ docker pull 127.0.0.1:5000/tinydemo:v1
v1: Pulling from tinydemo
0e8447578a07: Download complete
2bc0aae78406: Pull complete
Digest: sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6
Status: Downloaded newer image for 127.0.0.1:5000/tinydemo:v1
$ docker run --rm 127.0.0.1:5000/tinydemo:v1
hello from a private registry
Same digest after a full delete and re-pull. That is the whole product.
A registry is just files on disk: the v2 API by hand
curl is a fine registry client, and walking the v2 API by hand is the
fastest way to see what it stores. First the catalog:
$ curl http://127.0.0.1:5000/v2/_catalog
{"repositories":["tinydemo"]}
Then a manifest. It needs an Accept header, and here modern Docker
hand-pushed me a lesson: my image was pushed as an OCI index (BuildKit adds
attestation manifests), so asking for the Docker v2 manifest type fails loudly:
$ curl -s -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
http://127.0.0.1:5000/v2/tinydemo/manifests/v1
{"errors":[{"code":"MANIFEST_UNKNOWN","message":"OCI index found, but accept header does not support OCI indexes"}]}
Ask for what is there — an OCI index — and the tag resolves to a list of platform manifests:
$ curl -s -H 'Accept: application/vnd.oci.image.index.v1+json' \
http://127.0.0.1:5000/v2/tinydemo/manifests/v1
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:d03f8ed413b2575d6179ed6adffdd7ffb8591e77a221231b2394ff29f8bdb987",
"size": 669,
"platform": { "architecture": "amd64", "os": "linux" }
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:df5906856c1361fa64146d1f608cd3490f6f5560402eb42c789aba22d0f0d1f8",
"size": 836,
"annotations": { "vnd.docker.reference.type": "attestation-manifest" }
}
]
}
Digests also address a manifest directly. This is what every image is: one config blob plus a list of layer blobs:
$ curl -s -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
http://127.0.0.1:5000/v2/tinydemo/manifests/sha256:d03f8ed413b2575d6179ed6adffdd7ffb8591e77a221231b2394ff29f8bdb987
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:4f0b7a7716fcdef4654b26d9af2b72eae84802e591c204bf7a84f7ecec6c3ace",
"size": 1056
},
"layers": [
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:25f1d6b1951ac8eb3740558fe94cb83d377bdadf95fd9f98b50d2e1b96130471",
"size": 3630321 },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:2bc0aae784062adbc2ee3316342803159e3c931362853fee074c86d5196cbd1e",
"size": 132 }
]
}
And docker image inspect shows the client-side view of the same object:
$ docker image inspect 127.0.0.1:5000/tinydemo:v1 --format \
'Digest: {{.RepoDigests}}
ID: {{.Id}}
Size: {{.Size}} bytes
Arch: {{.Architecture}}/{{.Os}}'
Digest: [127.0.0.1:5000/tinydemo@sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6]
ID: sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6
Size: 12110898 bytes
Arch: amd64/linux
Now the punchline: look at the volume. A registry is a directory of content-addressed files plus a stack of tiny link files:
$ find /srv/registry/data/docker/registry/v2/repositories -type f
tinydemo/_manifests/tags/v1/current/link
tinydemo/_manifests/revisions/sha256/b7b67170.../link
tinydemo/_manifests/revisions/sha256/d03f8ed4.../link
tinydemo/_layers/sha256/25f1d6b1.../link
tinydemo/_layers/sha256/2bc0aae7.../link
...
$ cat .../repositories/tinydemo/_manifests/tags/v1/current/link
sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6
A tag is a text file containing a digest. Blobs live at
blobs/sha256/<first two hex>/<digest>/data, and the big one is exactly the
3,630,321-byte layer from the manifest:
$ ls -l .../blobs/sha256/25/25f1d6b1.../data
3630321 blobs/sha256/25/25f1d6b1.../data
Back up that directory and you have backed up the registry. There is no database to dump.
htpasswd: who is allowed to push
The registry above is wide open — anyone who can reach the port can push images your CI will later run. The quickest lock is basic auth via htpasswd. Generate a bcrypt entry from a throwaway container; I never install Apache on the host:
$ docker run --rm --entrypoint htpasswd httpd:2.4 -Bbn demo s3cret
demo:$2y$05$2YDFo9tp3TwgSR.z4O...
Run a second registry with that file mounted and auth turned on:
docker run -d --name auth-registry -p 5001:5000 \
-v /srv/registry/auth-data:/var/lib/registry \
-v /srv/registry/htpasswd:/auth/htpasswd:ro \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM=basic-realm \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
registry:2
Anonymous access dies at the door:
$ curl -sI http://127.0.0.1:5001/v2/
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Docker-Distribution-Api-Version: registry/2.0
docker push without credentials fails too — it cannot even resolve the
repository:
$ docker push 127.0.0.1:5001/tinydemo:v1
tag does not exist: 127.0.0.1:5001/tinydemo:v1
Log in and the same push goes through. Note Docker’s own warning: basic-auth
credentials sit unencrypted in ~/.docker/config.json, one more reason a
registry with auth belongs behind TLS:
$ docker login 127.0.0.1:5001 -u demo -p s3cret
WARNING! Using --password via the CLI is insecure. Use --password-stdin.
WARNING! Your credentials are stored unencrypted in '/home/awinx/.docker/config.json'.
Login Succeeded
$ docker push 127.0.0.1:5001/tinydemo:v1
The push refers to repository [127.0.0.1:5001/tinydemo]
2bc0aae78406: Pushed
44136fa355b3: Pushed
25f1d6b1951a: Pushed
0e8447578a07: Pushed
v1: digest: sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6 size: 855
htpasswd basic auth is fine for a one-team registry. It is not what real deployments run: production registries use token auth, where the registry hands the client a JWT signed by a token server after the client authenticates, so credentials are not replayed on every blob upload. When one shared login stops being acceptable, move to registry middleware, a hosted private registry, or Harbor.
TLS: never plain HTTP past localhost
The daemon treats loopback registries (127.0.0.1, localhost) as implicitly
insecure: it falls back to plain HTTP and accepts self-signed certificates.
Anywhere else it insists on HTTPS. Take the plain-HTTP registry from earlier,
reached through its non-loopback bridge address 172.17.0.1:5000:
$ docker push 172.17.0.1:5000/tinydemo:v1
failed to do request: Head "https://172.17.0.1:5000/v2/tinydemo/blobs/sha256:0e8447578a071c4873d45dcf9d8405abbe8cf27014df2ec12243717423667b99": EOF
The daemon dialed https:// against an HTTP listener and the connection died.
The fix is TLS. A self-signed certificate is one openssl command:
$ openssl req -x509 -newkey rsa:2048 -sha256 -days 2 -nodes \
-keyout domain.key -out domain.crt \
-subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1"
$ openssl x509 -in domain.crt -noout -subject -dates
subject=CN=127.0.0.1
notBefore=Oct 14 04:40:39 2026 GMT
notAfter=Oct 16 04:40:39 2026 GMT
The registry serves it directly with two environment variables — its own logs confirm TLS:
docker run -d --name tls-registry -p 5002:5000 \
-v /srv/registry/tls:/certs:ro \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
registry:2
level=info msg="restricting TLS version to tls1.2 or higher" ... version=2.8.3
level=info msg="listening on [::]:5000, tls" ... version=2.8.3
curl refuses to trust a self-signed certificate until you hand it the CA file;
with --cacert (a self-signed cert is its own CA) the API answers:
$ curl --cacert domain.crt https://127.0.0.1:5002/v2/
{}
$ curl https://127.0.0.1:5002/v2/
curl: (60) SSL certificate OpenSSL verify result: self-signed certificate (18)
And docker, on loopback, accepts the same registry with no configuration at all:
$ docker tag 127.0.0.1:5000/tinydemo:v1 127.0.0.1:5002/tinydemo:v1
$ docker push 127.0.0.1:5002/tinydemo:v1
The push refers to repository [127.0.0.1:5002/tinydemo]
2bc0aae78406: Pushed
44136fa355b3: Pushed
v1: digest: sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6 size: 855
Convenient on a laptop — and exactly why it is not a production posture. To pin down the boundary I issued a second self-signed certificate for the bridge address and ran the identical registry there:
$ openssl req -x509 -newkey rsa:2048 -sha256 -days 2 -nodes \
-keyout ext.key -out ext.crt \
-subj "/CN=172.17.0.1" -addext "subjectAltName=IP:172.17.0.1"
docker run -d --name tls-registry-ext -p 172.17.0.1:5003:5000 \
-v /srv/registry/tls:/certs:ro \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/ext.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/ext.key \
registry:2
curl, given the CA file, is happy — the registry and certificate are fine:
$ curl --cacert ext.crt https://172.17.0.1:5003/v2/_catalog
{"repositories":[]}
The docker daemon is not. Same binary, same certificate, one hop off loopback:
$ docker push 172.17.0.1:5003/tinydemo:ext
failed to do request: Head "https://172.17.0.1:5003/v2/tinydemo/blobs/sha256:25f1d6b1951ac8eb3740558fe94cb83d377bdadf95fd9f98b50d2e1b96130471": EOF
The production answers, in order of preference:
- A real certificate. Terminate TLS in front of the registry with nginx or Caddy using a certificate your clients already trust — a public CA if the registry is reachable, your internal PKI for a LAN.
- Distribute your CA to every client. Put the CA file at
/etc/docker/certs.d/<host:port>/ca.crton every machine that pulls, then restart the daemon. insecure-registriesin/etc/docker/daemon.json— the documented escape hatch that allows plain HTTP to named hosts:
{
"insecure-registries": ["172.31.0.9:5000"]
}
Option 3 also disables certificate verification for that host, which is why it belongs on a lab VLAN only: it turns “encrypted transport, questionable certificate” into “cleartext with basic-auth credentials in it”.
Garbage collection: the tax nobody schedules
A registry grows forever if you let it. Two surprises every operator hits:
One: docker rmi does not delete anything from the registry. The client
removes its local copy; the server copy stays until you explicitly delete it.
For the experiment I pushed a second, slightly different build as v2, then
aimed the DELETE API at the v1 manifest — resolved from its
Docker-Content-Digest header, because a registry deletes by digest, never by
tag:
$ curl -sI -u demo:s3cret -H 'Accept: application/vnd.oci.image.index.v1+json' \
http://127.0.0.1:5001/v2/tinydemo/manifests/v1 | grep -i docker-content-digest
Docker-Content-Digest: sha256:b7b67170a70ad8573367a5d872505896f113372f1a5668cb17468181b0577ab6
$ curl -s -u demo:s3cret -X DELETE \
http://127.0.0.1:5001/v2/tinydemo/manifests/sha256:b7b67170... -o /dev/null -w '%{http_code}\n'
405
405: deletion is disabled unless the registry starts with
REGISTRY_STORAGE_DELETE_ENABLED=true. After a restart with it enabled, the
same request is accepted and the tag disappears from the tag list:
$ curl -s -u demo:s3cret -X DELETE \
http://127.0.0.1:5001/v2/tinydemo/manifests/sha256:b7b67170... -o /dev/null -w '%{http_code}\n'
202
$ curl -s -u demo:s3cret http://127.0.0.1:5001/v2/tinydemo/tags/list
{"name":"tinydemo","tags":["v2"]}
Two: deleting a tag does not free the bytes. Unlinking a manifest leaves the blobs in place; only the garbage collector reclaims them. Its marking pass shows what is still reachable:
$ docker exec auth-registry registry garbage-collect /etc/docker/registry/config.yml
tinydemo: marking manifest sha256:4ba92a42...
tinydemo: marking blob sha256:25f1d6b1...
...
On my registry the blob-file count went from 14 to 13, and the second pass found nothing more to reclaim:
13 blobs marked, 0 blobs and 0 manifests eligible for deletion
Why the leftover: the deleted image’s manifest revisions were still linked
under _manifests/revisions, and revisions keep their blobs reachable — run
the collector until it reports nothing eligible, which in practice means a
cron job, not a one-shot. The tempting --delete-untagged flag is a trap for
index-based images; I dry-ran it before trusting it:
$ docker exec auth-registry registry garbage-collect --delete-untagged --dry-run \
/etc/docker/registry/config.yml
blob eligible for deletion: sha256:2306693e...
blob eligible for deletion: sha256:47c9af74...
blob eligible for deletion: sha256:1fadcff3...
Those are the live config and layer of the tagged v2 image — the child manifests of a tagged OCI index look “untagged” to the collector. Running that for real would have deleted an image I still serve. Dry-run everything.
The honest ledger
Running registry:2 is genuinely easy. The work is everything adjacent:
- Backups. The volume is the data. Copy it with restic or rsync, and rehearse a restore — I stole that rule wholesale from running my own Git server, where the backups you have actually restored are the only ones that count.
- GC on a schedule, plus an eye on
du. Storage growth is slow, silent, and always discovered at the worst moment. - Upgrades. A stateful service with an API your tools speak: version it, snapshot the volume, read the changelog.
- Access control is a project. htpasswd is one shared login. Token auth, per-team permissions, scanning and image signing are separate systems — if those matter, look at Harbor or a hosted registry instead of DIY.
And the honest exit: for “somewhere private to push a side project’s images”, a hosted private registry (GHCR, GitLab) costs nothing and comes with someone else’s uptime. If your images build reproducibly from Dockerfiles, a Git repo plus CI may be the real registry you need — connecting the self-hosted Git server you may already run with the build steps that produce the images. Self-host the registry when the images themselves are the asset: too big to rebuild quickly, containing binaries you cannot ship publicly, or needed in an air-gapped network.
If you do run one, the security conversation continues on the other side — what is inside your base images matters as much as where they live; see container security.
Checklist I would hand a teammate
registry:2with a named volume; bind the port to a private interface.- TLS with a real certificate, or your CA distributed to every client.
- Auth in front — htpasswd at minimum, token auth for more than one team.
REGISTRY_STORAGE_DELETE_ENABLED=true, GC on a cron, alerts on disk.- Back up the volume; test the restore.
- Delete by digest, then GC, then verify with
/v2/_catalog.
The registry is the rare self-hosted service where the honest risk is not the software — it is the operational debt of owning storage. Treat that debt deliberately and the private images are yours, cheaply and quietly.