Understanding how images are distributed: registry naming conventions, docker login, tag, push, pull, immutable digests for pinning, comparing Docker Hub and GHCR, and building your own private registry:2 with TLS and basic auth, plus the traps of rate limits and CI credentials.

After making Compose adaptable across environments in episode 11, in this episode we answer a question we've been passing over all along: where do those images come from, and how are they distributed? Since episode 0 we've been typing docker run nginx or docker pull postgres:16-alpine without ever questioning the mechanism behind it. The registry — where images are stored and shared — is the most underappreciated part of Docker's architecture, yet in the real world it's the "distribution pipeline" of all software.
Understand this well because the consequences are real: a wrong image name means the image lands somewhere you didn't expect; forgetting authentication means a mysteriously failing push; careless tags mean the team can't tell which version production is actually using; and credentials leaking in CI can expose your entire private image library. In companies, supply chain security incidents often start at the registry — not in the code.
In this episode we'll dissect the anatomy of an image name, master docker login, tag, push, and pull, understand the digest as an immutable identity and how to pin, compare Docker Hub with the GitHub Container Registry, build our own private registry based on registry:2 with TLS and basic auth, and close with the pitfalls that trip people up most: rate limits, the mutable latest, and credentials leaking in CI.
A registry is a service that stores and distributes Docker images. Imagine life without a registry: every time you wanted to run an application, you'd have to send image files manually from one machine to another — impractical, insecure, and unversionable. The registry solves three problems at once:
The analogy: a registry is the Git remote for images. docker pull is git clone, docker push is git push, and docker tag is giving a commit a name — in this case, a name for one image snapshot.
Every image has a full name with a specific structure. The complete form is:
[registry]/[namespace]/[repository]:[tag]
ghcr.io/arman-dwi-pangestu/my-app:1.0.0When parts are omitted, Docker fills in its defaults. Let's break it down:
| Example | Registry | Namespace | Repo | Tag |
|---|---|---|---|---|
nginx | docker.io | library | nginx | latest |
postgres:16-alpine | docker.io | library | postgres | 16-alpine |
ghcr.io/arman-dwi-pangestu/my-app:1.0.0 | ghcr.io | arman-dwi-pangestu | my-app | 1.0.0 |
localhost:5000/team/app:v2 | localhost:5000 | team | app | v2 |
docker.io (Docker Hub). Writing docker pull nginx actually means docker pull docker.io/library/nginx:latest.library is a special namespace for official images (nginx, postgres, redis, and so on). Non-official images on Docker Hub use the user/org name as the namespace, e.g. docker pull traefik/traefik means namespace traefik.latest. That needs to be watched out for — we discuss it in the pitfalls.One habit that's strongly recommended: always write the tag explicitly, and for production, write the full name including the registry. An image name is an address; a vague address produces unexpected pulls.
Private registries and pushes to any registry require authentication. The command always takes the form docker login <host>:
docker login
docker login ghcr.io -u arman-dwi-pangestu
docker logout ghcr.iodocker login without arguments means Docker Hub — you'll be asked for a username and password/token.Read & Write only for specific repos).read:packages / write:packages scope, or GITHUB_TOKEN in CI.docker logout clears local credentials — don't leave a login sitting on a shared machine.Warning
On Linux, credentials from docker login are stored (base64-encoded, not encrypted) in ~/.docker/config.json. If the machine is shared, configure a credential helper (e.g. docker-credential-pass or keychain/secret service integration) so credentials don't sit around as easily readable text. Never let config.json get committed into a repository.
docker tag doesn't copy an image — it creates another name/label for the same image (remember the commit analogy: a tag is a label on the same snapshot). Then docker push uploads the image's layers to the registry:
docker tag my-app:1.0.0 ghcr.io/arman-dwi-pangestu/my-app:1.0.0
docker push ghcr.io/arman-dwi-pangestu/my-app:1.0.0The push refers to repository [ghcr.io/arman-dwi-pangestu/my-app]
8cbe492...: Pushed
6e2f2ad...: Pushed
2c6e27a...: Layer already exists
1.0.0: digest: sha256:0b5e4a6c3d9f... size: 1734Note the line "Layer already exists" — this is proof of registry efficiency: layers already on the server aren't uploaded again. docker pull works the reverse: it only fetches layers the local machine doesn't have. That's why the same image on many servers doesn't mean the disk fills up repeatedly — layers are shared.
At the end of a push, Docker shows digest: sha256:.... What is it? A digest is the SHA-256 hash of the image's manifest — a cryptographic identity that is immutable. Meaning: the same digest always refers to exactly the same content, regardless of the tag.
my-app:latest can point to a different image each time it's pushed.my-app@sha256:0b5e4a6c... forever points to the same content.How to read an image's digest:
docker images --digests ghcr.io/arman-dwi-pangestu/my-app
docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/arman-dwi-pangestu/my-app:1.0.0For truly reproducible deployments, pin the image to a digest:
services:
api:
image: ghcr.io/arman-dwi-pangestu/my-app@sha256:0b5e4a6c3d9f...e3fWith pinning, docker compose pull always fetches exactly the same content — no matter what happens to the latest tag. The trade-off: you have to consciously update the digest on every new release (and that's actually good — there are no "invisible" changes). A balanced choice: use version tags (1.0.0) for convenience, and digests for critical deployments or while tags are being moved.
The two most-used public registries have different characters. The choice affects your workflow:
| Aspect | Docker Hub | GHCR (ghcr.io) |
|---|---|---|
| Default access | Public; private repos paid | Public/private follows GitHub repo visibility |
| Official images | Yes (largest ecosystem) | No (user/org only) |
| CI authentication | Access token, needs setup | Automatic GITHUB_TOKEN in Actions |
| Pull rate limit (anonymous) | ~100/6 hours per IP | No practical limit for public |
| Best for | Public & community images | Private images + teams already on GitHub |
Docker Hub's rate limit is the most common reason pulls suddenly fail: anonymous users are limited (about 100 pulls per 6 hours per IP), and authenticated users get larger quotas. In CI with shared runners, this limit is hit quickly. The solutions: log in (docker login) before pulling in CI, or move private images to a registry that doesn't apply aggressive limits, like GHCR.
A sensible rule of thumb: public → Docker Hub, private → GHCR. If your code is already on GitHub, GHCR eliminates extra cost and setup — the token is already available in Actions, and image visibility follows the repository.
Not every image may touch a public registry. For internal images — code that can't be public yet — you need a private registry. Docker provides the official registry:2 image that can be running within minutes.
Step 0 — Local test (no TLS):
docker run -d -p 5000:5000 --name registry registry:2
docker tag my-app:1.0.0 localhost:5000/my-app:1.0.0
docker push localhost:5000/my-app:1.0.0
docker pull localhost:5000/my-app:1.0.0localhost is a special case Docker permits without TLS. As soon as you leave localhost, two things are mandatory: TLS (so traffic is encrypted and the daemon is willing to use it) and authentication (so not just anyone can push).
Step 1 — Prepare basic auth credentials with htpasswd:
mkdir -p registry/{auth,certs}
docker run --rm --entrypoint htpasswd registry:2 \
-Bbn arman 's3cr3t-kuat' > registry/auth/htpasswd-B uses bcrypt (required for registry:2), -b supplies the password via argument, -n prints to stdout. This htpasswd file contains the username and password hash.
Step 2 — Prepare the TLS certificate:
openssl req -newkey rsa:2048 -nodes -keyout registry/certs/domain.key \
-x509 -days 365 -out registry/certs/domain.crt \
-subj "/CN=registry.example.com"Tip
For real production, don't use self-signed — use a certificate from a public CA (e.g. Let's Encrypt). Self-signed forces you to copy domain.crt and trust it on every client machine, which is easily missed. The name in CN/SAN must also match the hostname used in the image name (registry.example.com:5000/...) — otherwise TLS verification fails.
Step 3 — Run the registry with TLS + auth:
docker run -d -p 5000:5000 --name registry \
-v "$PWD/registry/auth":/auth \
-v "$PWD/registry/certs":/certs \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
-e REGISTRY_HTTP_SECRET=random-string-panjang \
registry:2The REGISTRY_AUTH* variables enable basic auth, REGISTRY_HTTP_TLS* point to the certificate, and REGISTRY_HTTP_SECRET is the key for signing internal tokens — don't use a guessable value.
Step 4 — Trust the certificate on the client, then log in and push:
sudo mkdir -p /etc/docker/certs.d/registry.example.com:5000
sudo cp registry/certs/domain.crt /etc/docker/certs.d/registry.example.com:5000/ca.crt
sudo systemctl restart dockerdocker login registry.example.com:5000 -u arman
docker tag my-app:1.0.0 registry.example.com:5000/my-app:1.0.0
docker push registry.example.com:5000/my-app:1.0.0The /etc/docker/certs.d/<host>:<port>/ca.crt directory structure is Docker's official mechanism for trusting a CA per registry. Don't replace it with --insecure-registry — that completely disables TLS verification and opens the door to man-in-the-middle attacks.
Caution
registry:2 running as a container stores its data inside the container layer. Once the container is deleted (or docker run again with the same name), all your private images are gone. Always attach a named volume (or bind mount) at /var/lib/registry — exactly the episode 8 lesson. A registry without persistent storage is a registry that loses data on its first maintenance.
The most expensive mistake in this entire episode: registry credentials leaking into an image. The wrong pattern:
FROM alpine:3.20
RUN docker login -u $CI_USER -p $CI_PASS ghcr.io \
&& docker pull ghcr.io/private/helper \
&& docker login --logout ghcr.ioThe problem: a RUN command writes to a new permanent layer — the line docker login -u $CI_USER -p $CI_PASS is recorded in the image history (visible with docker history), and the credentials can leak to anyone with image access. The correct pattern:
docker login inside a Dockerfile. Authenticating to a registry is the business of the machine building the image, not the image itself.GITHUB_TOKEN — no secret string needs to be copied anywhere:- name: Login ke GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}The principle is simple: credentials never enter the image; credentials only live in the environment that uses them. If an image can display a secret from inside a container, the design is wrong.
Docker Hub rate limit. Anonymous pulls are limited to ~100 per 6 hours per IP. On CI or shared servers, this often becomes a "mysterious pull failure". Log in before pulling, or move images to a registry without aggressive limits.
The mutable latest tag. latest tells you nothing: it can point to a different version at any time and break reproducibility. Always tag with semantic versions (1.0.0), and for critical deployments, pin to a digest.
Credentials in images / in CI files. docker login in a Dockerfile, tokens in compose.yaml, or a committed .env are leak paths. Credentials live in a secret store, not in shared files.
A self-signed registry without client trust. Pushing to a registry using a self-signed cert fails with x509: certificate signed by unknown authority because the CA isn't trusted. Copy the CA to /etc/docker/certs.d/<host>:<port>/ca.crt on every client — or use a public CA.
Hostname not matching the certificate's CN. A certificate for registry.example.com isn't valid for registry or an IP. The name in docker login and in the image tag must exactly match the CN/SAN.
A registry container without a persistent volume. registry:2 storing data in the container layer loses all images when the container is deleted. Attach a volume at /var/lib/registry.
Repeatedly overwriting the same tag. docker push ...:1.0.0 twice means 1.0.0 points to new content — the old history is lost from that tag. For images that must be immutable, use immutable tags (e.g. 1.0.0-<build> or digests) and enable tag protection features in the registry.
In this episode 12 we've understood the image distribution path: the registry as an efficient storage and distribution place (the same layers aren't re-downloaded), the anatomy of an image name (registry/namespace/repo:tag with docker.io and library as defaults), the authentication and publication flow (docker login, tag, push, pull), the sha256 digest as an immutable identity for reproducible pinning, a Docker Hub vs GHCR comparison with their rate limits, building a private registry based on registry:2 with TLS and basic auth (htpasswd, openssl, certs.d), and avoiding credential leaks in CI.
Core takeaways:
docker tag only creates a label; layers are sent only if they don't exist yet.registry:2.certs.d, not --insecure-registry.GITHUB_TOKEN).Now your images are stored and distributed cleanly. But there's a question that becomes more urgent the easier images are to distribute: are the images you pull from a registry safe to run? A registry can contain malicious images — and even images you build yourself can carry a vulnerable base image. In the next episode, episode 13, we enter the realm of Container Security & Hardening Best Practices: the risks of running containers as root, limiting capabilities, read-only filesystems, resource limits, seccomp/AppArmor, all the way to Rootless Docker. See you in episode 13!