Docker Registries and Image Distribution
How Docker registries work: image references and digests, Docker Hub vs GHCR/ECR/GitLab, login and auth, tag strategy, pull-through caches, rate limits, and cleanup.
You build an image on your laptop or in CI, but that is not where it runs. It has to travel to a production node, a Kubernetes cluster, a teammate's machine. The thing that moves it is a registry: a server that stores images and hands them out on request. Most of the confusing parts of Docker distribution - why latest bit you, why a pull from CI suddenly failed with a rate-limit error, why "the same tag" behaves differently on two machines - come from not understanding how the registry names, stores, and serves images. This guide covers the registry model end to end: the image reference format, the difference between tags and digests, auth, a tag strategy that survives contact with production, and how to stop a registry from growing forever.
What a registry actually is
A registry is a content-addressable store for container images, plus an HTTP API for pushing and pulling them. When you run docker pull nginx, the daemon talks to a registry, downloads a small JSON manifest that lists the image's layers, then downloads each layer (a compressed tarball) it does not already have. Layers are addressed by their content hash, so if you already have a layer from a previous pull, it is skipped. That is why the second pull of a related image is fast.
Do not confuse three things that sound alike:
- A registry is the whole server (for example
registry-1.docker.ioorghcr.io). - A repository is one named collection of images inside it (for example
library/nginxormyorg/api). - A tag is a human label pointing at one specific image version inside a repository (for example
1.25orlatest).
The registry is dumb on purpose. It does not build, test, or run anything. It stores blobs and manifests and serves them over HTTP with authentication in front. Everything clever - caching, deduplication, rollout - is built on top of that simple contract.
Docker Hub vs private registries
Docker Hub (docker.io) is the default public registry and the one Docker reaches for when you do not name another. It hosts the official images (nginx, postgres, python) and countless community ones. It is fine for pulling public base images, but for your own images you almost always want a private registry, and where it lives usually follows where your code and infrastructure already are:
- GHCR (GitHub Container Registry,
ghcr.io) - images live next to your GitHub repos, auth uses a GitHub token, and it is the natural choice when you build in GitHub Actions. - Amazon ECR (
<account>.dkr.ecr.<region>.amazonaws.com) - the AWS-native registry, with IAM-based auth and tight integration with ECS/EKS. Login is viaaws ecr get-login-password. - GitLab Container Registry (
registry.gitlab.com) - built into every GitLab project, with aCI_REGISTRYtoken available inside GitLab CI jobs. - Self-hosted - the open-source
registry:2image, or Harbor if you want a UI, scanning, and retention policies on your own hardware.
The mechanics are identical across all of them, because they all speak the same OCI distribution API. Switching registries is mostly a matter of changing the hostname in your image references and how you authenticate. Pick the one that sits closest to your build and deploy pipeline; the integration and token handling is what saves you time, not the storage itself.
The image reference format
Every image name is a compressed address. The full form is:
[registry-host[:port]/]namespace/repository[:tag | @digest]
Docker fills in defaults aggressively, which is why short names work and also why they surprise you. These three references are the same image:
nginx
docker.io/library/nginx:latest
registry-1.docker.io/library/nginx:latest
The implicit defaults Docker applies when a part is missing:
- No registry host ->
docker.io(Docker Hub). - No namespace on a Docker Hub image ->
library/, the namespace for official images. Songinxbecomeslibrary/nginx. - No tag ->
:latest. Note this is just a tag named "latest," not a promise that it is the newest anything (more on that below).
The moment you use any registry other than Docker Hub, you must spell out the host, because the docker.io default only applies to bare names:
ghcr.io/myorg/api:1.4.0
123456789.dkr.ecr.eu-west-1.amazonaws.com/api:1.4.0
registry.gitlab.com/mygroup/myproject/api:1.4.0
One trap worth knowing: Docker decides whether the first path segment is a registry host by looking for a dot or a colon (or localhost). So myorg/api is treated as a Docker Hub repo in the myorg namespace, while registry.local/api is treated as the host registry.local. If your private registry hostname has no dot, Docker will misread it as a Hub namespace.
Tags vs immutable digests
A tag is a mutable pointer. myapp:1.4.0 points at some image today, but nothing stops someone from pushing a different image to that same tag tomorrow. Even nginx:1.25 gets rebuilt with new patch releases and security fixes over time. Tags are convenient labels, not guarantees.
A digest is the opposite: an immutable, content-addressed name. It is the SHA-256 hash of the image manifest, written as @sha256:..., and it can only ever refer to exactly one image. If a single byte of the content changes, the digest changes.
nginx:1.25 # a tag - can be repointed to new content
nginx@sha256:a1b2c3... # a digest - always the exact same bytes
You can see and pin digests directly:
docker pull nginx:1.25
docker inspect --format='{{index .RepoDigests 0}}' nginx:1.25
# nginx@sha256:9f8e... <- pin THIS for reproducibility
This matters more than it looks. If your Kubernetes manifest or Dockerfile FROM line says nginx:1.25, two deploys a month apart can silently run different bytes, because the tag moved underneath you. Pin by digest and the deploy is reproducible: the exact image that passed your tests is the exact image that runs in production, forever. A common pattern is to build and tag semantically for humans, then resolve to a digest in CI and deploy by digest so nothing can drift.
# Reproducible base: pin the digest, keep the tag as a comment for readers
FROM nginx:1.25@sha256:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e...
Login, pull, and push
Registries authenticate over HTTP. The three commands are the same everywhere:
docker login ghcr.io # prompts for username + token
docker pull ghcr.io/myorg/api:1.4.0
docker tag api:1.4.0 ghcr.io/myorg/api:1.4.0
docker push ghcr.io/myorg/api:1.4.0
The key detail is what you put in the password field. Use a token, not your account password. Every registry issues scoped, revocable tokens for exactly this, and they are safer because you can limit their permissions and revoke one without changing your login:
- GHCR - a GitHub Personal Access Token with
read:packages/write:packages, or the built-inGITHUB_TOKENinside Actions. - ECR - a short-lived token from
aws ecr get-login-password, piped straight into login. It expires in 12 hours, so CI regenerates it each run. - GitLab - the
CI_JOB_TOKENinside CI, or a deploy token for external systems.
# ECR: no stored password, just a fresh token each time
aws ecr get-login-password --region eu-west-1 \
| docker login --password-stdin \
--username AWS \
123456789.dkr.ecr.eu-west-1.amazonaws.com
# GHCR in CI: feed the token on stdin, never as a shell argument
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
Two habits keep credentials out of trouble. Always use --password-stdin rather than --password <value>, so the secret never lands in your shell history or the process list. And know where docker login stores the result: ~/.docker/config.json. By default that file holds credentials base64-encoded (not encrypted), so on shared or CI machines use a credential helper (docker-credential-ecr-login, osxkeychain, and so on) to keep them out of a plaintext file.
A tag strategy that works
Tags are cheap and a registry lets you point many at the same image, so the goal is a scheme that serves both humans and machines. A strategy that holds up in production layers three kinds of tags:
- Immutable version tags - a real, unique identifier per build, usually semver:
api:1.4.0,api:1.4.1. These never move. This is what you deploy and roll back to. - Moving tags - convenience pointers that get repushed to the newest matching build:
latest,stable,1.4,1. Handy for "give me the current one," dangerous to deploy from, because they change under you. - SHA / commit tags - a tag carrying the git commit, like
api:git-3f9a2c1orapi:main-3f9a2c1. These tie an image directly back to the exact source that produced it, which is invaluable when debugging "what is actually running."
A typical CI build pushes several tags at once for the same image:
SHA=$(git rev-parse --short HEAD)
docker build -t api .
docker tag api ghcr.io/myorg/api:1.4.0 # immutable release
docker tag api ghcr.io/myorg/api:git-$SHA # traceable to source
docker tag api ghcr.io/myorg/api:latest # moving convenience pointer
docker push --all-tags ghcr.io/myorg/api
The rule to internalize: deploy from immutable tags or digests, never from latest. latest is fine for a quick local docker run or a "grab the current build" convenience, but a production system pinned to latest will one day pull a different image than the one you tested, with no record of what changed. Reserve moving tags for humans and pin machines to something that cannot move.
Pull-through caches and rate limits
Public registries throttle you. Docker Hub enforces pull rate limits by source: anonymous pulls are capped per IP over a rolling window, and authenticated free accounts get a higher but still finite allowance. In a CI system where dozens of jobs each pull node or python from a shared set of egress IPs, you hit that ceiling fast, and builds start failing with toomanyrequests: You have reached your pull rate limit.
The fix is a pull-through cache (also called a registry mirror): a registry you control that sits in front of Docker Hub. The first time anyone asks it for nginx:1.25, it fetches from Hub, stores the layers, and serves them; every pull after that is served locally without touching Hub at all. That both eliminates the rate-limit problem and makes pulls dramatically faster, since the image is now on your network.
// /etc/docker/daemon.json - point the daemon at your mirror
{
"registry-mirrors": ["https://mirror.internal.example.com"]
}
You can run the stock registry:2 image in pull-through mode, or use Harbor, Artifactory, or a cloud provider's mirror. The other half of the answer is simply to authenticate your CI pulls even for public images, because logged-in requests get a higher limit than anonymous ones. In practice, teams pulling public base images at any volume do both: a mirror to cut traffic, plus authenticated pulls so what does reach Hub counts against a real quota rather than a shared anonymous one.
Cleanup and retention
A registry that only ever gains images grows without bound, and storage bills and slow listings eventually force the issue. Every CI run that pushes a git-<sha> tag adds another image; without a policy, you accumulate thousands of builds nobody will ever deploy.
Deleting a tag is not the whole job. Removing a tag only removes the pointer; the underlying layers (blobs) stay on disk until a separate garbage collection pass reclaims space from anything no longer referenced by any tag. On the open-source registry that is an explicit step:
# On a self-hosted registry:2 - delete unreferenced blobs
docker exec registry bin/registry garbage-collect \
/etc/docker/registry/config.yml
Managed registries give you retention policies so you do not script this by hand. The patterns that matter:
- Keep the last N versions per repository and expire older ones. ECR lifecycle policies and GitLab cleanup policies both do this.
- Expire by age or by pattern - delete untagged images after a few days, or delete
git-*and other ephemeral CI tags after a week, while keeping semver release tags forever. - Never auto-delete release tags. Age-out the noisy per-commit and untagged images; protect the versions you might need to roll back to.
// ECR lifecycle rule: expire untagged images after 14 days
{
"rules": [{
"rulePriority": 1,
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 14
},
"action": { "type": "expire" }
}]
}
Set retention up early, while the registry is small and the rules are obvious. It is far easier to define "keep releases, expire CI tags after a week" on day one than to untangle which of ten thousand images are safe to delete after a year of unbounded growth.
The shape of it
A registry is a plain content store: it holds layers and manifests and serves them over an authenticated HTTP API, and everything else is built on that. Image names are addresses with heavy defaults - docker.io, library/, and :latest filled in when you leave them out - so spell out the host for any private registry. Tags are mutable pointers and digests are immutable content hashes; deploy from immutable version tags or pin @sha256: digests so the bytes you tested are the bytes that run. Authenticate with scoped tokens fed on stdin, not passwords. Layer your tags (semver for release, SHA for traceability, moving pointers for convenience) and never deploy from a moving one. Put a pull-through cache in front of Docker Hub to beat rate limits, and give the registry a retention policy on day one so it does not grow forever. Get those right and image distribution stops being a source of surprises and becomes plumbing you can trust.