Registries, tags and sharing images

Tag, push and pull, log in safely, and run a local registry.

Beginner22 min · lesson 14 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (2 files, 1 KB): registry.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/registry.tar.gz && tar -xzf registry.tar.gz, which creates ~/lab/registry/. SHA-256: df0ab7a9b46b1034e793a62ff16e545df4646aa61c5edcb8ec3a16b4b313bd81

While this course was being tested, the lab VMs shared one public IP address, and Docker Hub started answering image pulls with 429 Too Many Requests. The response headers explained why: ratelimit-remaining: 0. Every docker pull, every docker run of an image that wasn't cached yet, and every CI job on that address counted against the same anonymous allowance. Registries are where images come from and where yours go. How you name, push, pull and authenticate decides what you run, and whether you can get it at all.

You'll run a registry of your own in a container, so you can tag, push and pull without an account. Then you'll put a password in front of it and see what docker login stores, and where. Use the main lab VM (secopslog-docker) for the whole lesson, in ~/lab/registry, where the lesson files (Dockerfile and notes.txt) unpack. The credential-helper section near the end installs a package and a GPG key. It's optional on a first pass, and its last step removes them again.

What an image name says

A full image reference is registry/namespace/repository:tag. nginx:1.31-alpine is short for docker.io/library/nginx:1.31-alpine. When the first part isn't a host name, Docker assumes Docker Hub (docker.io), and official images live in the library namespace. ghcr.io/acme/payments/api:2.4.1 names the GitHub Container Registry, a namespace that can have several levels, the repository api and the tag 2.4.1. A first component that contains a dot or a colon, or is localhost, is a registry address, so localhost:5000/lab-notes:1.0 means "the registry at localhost port 5000". A tag is a movable label. A digest (@sha256: followed by 64 hex characters) names exact content and can't move.

A registry of your own

Build a tiny image and start the official registry:3 image (the CNCF Distribution registry) with a named volume for its storage. Then give your image a second name that points at that registry:

Dockerfile
FROM alpine:3.24
COPY notes.txt /notes.txt
CMD ["cat", "/notes.txt"]
ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ docker build -q -t lab-notes:1.0 .
sha256:1a604be23ec1f8ee8c057a4143d89c540e0b76a8d905dbba404d1134f666ba2a
$ docker run -d --name lab-registry -p 127.0.0.1:5000:5000 -v lab-registry-data:/var/lib/registry registry:3
d65fa6c670cf16cf1a0e5bdfc2abba1ec5e9a6a4f04019e7aa5a9047229ef597
$ docker tag lab-notes:1.0 localhost:5000/lab-notes:1.0
$ docker image ls lab-notes; docker image ls localhost:5000/lab-notes
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-notes:1.0 1a604be23ec1 13.5MB 4.19MB IMAGE ID DISK USAGE CONTENT SIZE EXTRA localhost:5000/lab-notes:1.0 1a604be23ec1 13.5MB 4.19MB

docker tag copied nothing. Both names show the same ID (1a604be23ec1), one image with two references. The registry is published on 127.0.0.1 only, which keeps it off the network, and it speaks plain HTTP. Docker refuses plain HTTP to a registry unless it's on a loopback address or listed in the daemon's insecure-registries. docker info on the lab VM lists 127.0.0.0/8 and ::1/128 as the defaults. A registry anyone else uses needs TLS.

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ docker push localhost:5000/lab-notes:1.0
The push refers to repository [localhost:5000/lab-notes] 65aa65fbe3c6: Pushed 44136fa355b3: Pushed 5942515a90ba: Pushed d3531b7a4f21: Pushed 1.0: digest: sha256:1a604be23ec1f8ee8c057a4143d89c540e0b76a8d905dbba404d1134f666ba2a size: 855
$ curl -s http://localhost:5000/v2/_catalog; curl -s http://localhost:5000/v2/lab-notes/tags/list
{"repositories":["lab-notes"]} {"name":"lab-notes","tags":["1.0"]}

The push uploaded the image's layers and config, plus the provenance attestation BuildKit adds by default, and printed the digest of what it pushed. The registry's HTTP API (/v2/_catalog, /v2/<name>/tags/list) now lists the repository and its tag. Your IDs and digests will differ, since they depend on build time and content.

Delete the local copies and pull the image back, as another machine would:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ docker image rm lab-notes:1.0 localhost:5000/lab-notes:1.0
Untagged: lab-notes:1.0 Untagged: localhost:5000/lab-notes:1.0 Deleted: sha256:1a604be23ec1f8ee8c057a4143d89c540e0b76a8d905dbba404d1134f666ba2a
$ docker pull localhost:5000/lab-notes:1.0
1.0: Pulling from lab-notes 65aa65fbe3c6: Pulling fs layer d3531b7a4f21: Pulling fs layer 65aa65fbe3c6: Already exists d3531b7a4f21: Already exists 5942515a90ba: Download complete 44136fa355b3: Already exists 65aa65fbe3c6: Pull complete Digest: sha256:1a604be23ec1f8ee8c057a4143d89c540e0b76a8d905dbba404d1134f666ba2a Status: Downloaded newer image for localhost:5000/lab-notes:1.0 localhost:5000/lab-notes:1.0
$ docker run --rm localhost:5000/lab-notes:1.0
release notes 1.0
$ docker image ls --digests localhost:5000/lab-notes
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE localhost:5000/lab-notes 1.0 sha256:1a604be23ec1f8ee8c057a4143d89c540e0b76a8d905dbba404d1134f666ba2a 1a604be23ec1 About an hour ago 13.5MB

Already exists means those blobs were still in the local store, so only the missing ones were downloaded. The alpine:3.24 base image, for one, was never deleted. The Digest: line matches the digest the push printed, so the bytes are the same. With the containerd image store (the default for new Docker 29 installs) the image ID is that same digest, which is why IMAGE ID begins 1a604be23ec1. The image is single-platform here (linux/arm64, built on the arm64 lab VM). On an amd64 machine your build is linux/amd64.

From build to another machine
1docker build
image in the local store
2docker tag
add a name with a registry address
3docker push
blobs + manifest, digest printed
4registry
tags point at digests
5docker pull
by tag or by digest
The digest printed by push and by pull is the check that both sides have the same content.

Tags move, digests do not

Push the same image as latest, then build 1.1 and move latest to it:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ docker tag localhost:5000/lab-notes:1.0 localhost:5000/lab-notes:latest && docker push -q localhost:5000/lab-notes:latest
localhost:5000/lab-notes:latest
$ echo 'release notes 1.1' > notes.txt && docker build -q -t localhost:5000/lab-notes:1.1 .
sha256:bdc3c288264f7a68ead49f9a7d4dbde07d089a360b3b71e9e5d92f3781eb17b9
$ docker push -q localhost:5000/lab-notes:1.1 && docker tag localhost:5000/lab-notes:1.1 localhost:5000/lab-notes:latest && docker push -q localhost:5000/lab-notes:latest
localhost:5000/lab-notes:1.1 localhost:5000/lab-notes:latest
$ curl -s http://localhost:5000/v2/lab-notes/tags/list
{"name":"lab-notes","tags":["1.0","1.1","latest"]}
$ curl -sI -H 'Accept: application/vnd.oci.image.index.v1+json' http://localhost:5000/v2/lab-notes/manifests/latest | grep -i docker-content-digest
Docker-Content-Digest: sha256:bdc3c288264f7a68ead49f9a7d4dbde07d089a360b3b71e9e5d92f3781eb17b9

Asked for latest, the registry now returns the digest of the 1.1 build. It's the same value the second docker build printed as its ID. A machine that pulled latest yesterday and one that pulls it today run different code, and neither command shows it. Anyone who can push to a tag your servers deploy decides what those servers run next. Use explicit version tags, and where it matters, deploy by digest:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ docker run --rm $(docker image inspect -f '{{index .RepoDigests 0}}' localhost:5000/lab-notes:1.0)
release notes 1.0

RepoDigests holds the reference localhost:5000/lab-notes@sha256: followed by the full 1.0 digest, and running that reference gives 1.0 no matter where any tag points. Promotion by digest, and policies that make tags immutable, are covered in "Tags, digests and promotion" in Docker in depth.

A registry that wants a password

The registry supports basic authentication against an htpasswd file with bcrypt hashes. The htpasswd tool isn't in the registry image. The official Apache httpd image ships it, so a throwaway container from that image writes the file. Docker pulls httpd:2.4-alpine first if it isn't on the VM. You can remove it afterwards with docker image rm httpd:2.4-alpine if you like:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ mkdir -p auth && docker run --rm --entrypoint htpasswd httpd:2.4-alpine -Bbn labuser lab-only-password > auth/htpasswd && cut -c1-12 auth/htpasswd
labuser:$2y$
$ docker run -d --name lab-registry-auth -p 127.0.0.1:5001:5000 -v "$PWD/auth":/auth:ro -e REGISTRY_AUTH=htpasswd -e REGISTRY_AUTH_HTPASSWD_REALM=lab -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd registry:3
f261bb89762700614324064c2179e12c0412501c9530f6c8919ce35ac01d522c
$ docker tag localhost:5000/lab-notes:1.1 localhost:5001/lab-notes:1.1 && docker push localhost:5001/lab-notes:1.1
The push refers to repository [localhost:5001/lab-notes] 45d315507b81: Waiting 7e23caed1067: Waiting 44136fa355b3: Waiting d3531b7a4f21: Waiting push access denied, repository does not exist or may require authorization: authorization failed: no basic auth credentials

$2y$ marks a bcrypt hash. This lab step passes a test password on the command line. That leaves it in shell history and in the process list, so do it only for throwaway credentials. Without credentials the push stopped with no basic auth credentials and exit status 1. A registry that knows you but refuses the repository says denied instead, which usually means a wrong namespace or an account without push rights. Log in:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ echo lab-only-password | docker login localhost:5001 -u labuser --password-stdin
WARNING! Your credentials are stored unencrypted in '/home/ubuntu/.docker/config.json'. Configure a credential helper to remove this warning. See https://docs.docker.com/go/credential-store/ Login Succeeded
$ cat ~/.docker/config.json
{ "auths": { "localhost:5001": { "auth": "bGFidXNlcjpsYWItb25seS1wYXNzd29yZA==" } } }
$ jq -r '.auths["localhost:5001"].auth' ~/.docker/config.json | base64 -d; echo
labuser:lab-only-password
$ docker push -q localhost:5001/lab-notes:1.1
localhost:5001/lab-notes:1.1
$ docker logout localhost:5001 && cat ~/.docker/config.json
Removing login credentials for localhost:5001 { "auths": {} }

Docker printed the problem in its own words: the credentials are stored unencrypted. The auth value in ~/.docker/config.json is user:password in base64, which base64 -d reverses in one command. Anyone who can read that file (a backup, a leaked home directory, a CI cache) can push as you. --password-stdin keeps the secret out of the command line. In real use, feed it from a file or your CI system's secret store rather than echo. docker logout removed the entry.

A credential helper instead of config.json

A credential helper is a small program (docker-credential-<name>) that the Docker CLI calls to store and fetch credentials. The secret then lives in a proper store. That can be the macOS Keychain (osxkeychain), Windows Credential Manager (wincred) or Docker Desktop's own helper. On a Linux server it can be pass, which keeps each secret as a GPG-encrypted file. "credsStore" in config.json sets the helper for every registry, and "credHelpers" sets one per registry.

Watch out
Run this part on the main lab VM, in the same shell as the rest of the lesson. It installs the pass package, a GPG key and a binary in /usr/local/bin, which is fine on a disposable VM, and it switches Docker on this VM to the helper. The clean-up step at the end of the section undoes all of it, so later courses start from the default config.json. The GPG key has no passphrase so the steps stay short; on a real machine, protect it with one.

The password-protected registry lab-registry-auth from the previous section is still running; check before you start:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ docker ps --filter name=lab-registry-auth --format '{{.Names}}: {{.Status}}'
lab-registry-auth: Up Less than a second
ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ sudo apt-get install -y -qq pass >/dev/null 2>&1 && pass version | grep -o "v[0-9.]*"
v1.7.4
$ V=v0.9.12 A=$(dpkg --print-architecture) && curl --retry 3 --retry-all-errors -fsSLO https://github.com/docker/docker-credential-helpers/releases/download/$V/docker-credential-pass-$V.linux-$A && curl --retry 3 --retry-all-errors -fsSLO https://github.com/docker/docker-credential-helpers/releases/download/$V/checksums.txt && sha256sum -c --ignore-missing checksums.txt
docker-credential-pass-v0.9.12.linux-arm64: OK
$ sudo install -m 0755 docker-credential-pass-v0.9.12.linux-$(dpkg --print-architecture) /usr/local/bin/docker-credential-pass && docker-credential-pass version
docker-credential-pass (github.com/docker/docker-credential-helpers) v0.9.12
$ gpg --batch --passphrase '' --quick-gen-key 'lab registry creds <lab@example.invalid>' default default never 2>&1 | grep -c 'revocation certificate stored'
1
$ pass init lab@example.invalid
mkdir: created directory '/home/ubuntu/.password-store/' Password store initialized for lab@example.invalid
$ echo '{ "credsStore": "pass" }' > ~/.docker/config.json && cat ~/.docker/config.json
{ "credsStore": "pass" }

The helper comes from the docker/docker-credential-helpers releases on GitHub, checked against the release's checksums.txt before installing. That catches a corrupted download, not a compromised release, because the checksum file comes from the same place. On servers, prefer a distribution package or verify the release's signature or provenance. dpkg --print-architecture picks the right build (arm64 on the lab VMs, amd64 on most PCs). The grep -c only condenses gpg's long output to one line: 1 means the key was created. pass init creates the password store encrypted for that key, and the one-line config.json switches Docker to the helper. That echo replaces the whole file, which here only held the empty auths left by the logout. On a machine with an existing config.json, add the credsStore key to it instead. Log in again:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ echo lab-only-password | docker login localhost:5001 -u labuser --password-stdin
Login Succeeded
$ cat ~/.docker/config.json
{ "auths": { "localhost:5001": {} }, "credsStore": "pass" }
$ find ~/.password-store -name '*.gpg'
/home/ubuntu/.password-store/docker-credential-helpers/bG9jYWxob3N0OjUwMDE=/labuser.gpg
$ docker-credential-pass list; echo
{"localhost:5001":"labuser"}
$ docker logout localhost:5001 && docker-credential-pass list; echo
Removing login credentials for localhost:5001 {}

No warning this time. config.json records only that a login for localhost:5001 exists. The secret is in labuser.gpg under a directory named after the registry address in base64, and docker-credential-pass list shows the server-to-user mapping without the password. Logout removed it from the store.

Now undo the section. Deleting config.json drops credsStore, so every later docker login on this VM uses the default file again. The lab VM had no GPG keys before, so removing ~/.gnupg removes only the lab key. gpgconf --kill gpg-agent stops the agent that still has the key loaded:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ rm ~/.docker/config.json gpgconf --kill gpg-agent && rm -rf ~/.password-store ~/.gnupg sudo rm /usr/local/bin/docker-credential-pass sudo apt-get remove -y -qq pass >/dev/null 2>&1 test ! -e ~/.docker/config.json && echo "config.json removed" command -v docker-credential-pass pass || echo "helper and pass removed"
config.json removed helper and pass removed

Docker Hub: logging in and pull limits

Docker Hub doesn't use a typed password by default. Plain docker login starts a device-code flow. It prints a one-time code and a URL. You confirm in a browser where you're signed in (with your normal two-factor login), and the CLI receives a token. Only docker login -u <user> asks for a secret. For CI and scripts, create a personal access token (or an organization access token) on Docker Hub with the narrowest scope that works, and pass it with --password-stdin. Never log in with your account password in automation.

terminal (Docker Hub, interactive)
docker login
# prints a one-time device confirmation code and https://login.docker.com/activate;
# confirm the code in the browser, then the CLI reports "Login Succeeded"
# CI: a personal access token from the job's secret store
printf '%s' "$DOCKERHUB_TOKEN" | docker login -u <DOCKERHUB_USER> --password-stdin

Docker Hub limits pulls. Its documentation gives 100 pulls per 6 hours for unauthenticated users, counted per IPv4 address or IPv6 /64. Signed-in Personal accounts get 200 per 6 hours, and paid plans aren't limited. A pull counts when the manifest is fetched with GET. A HEAD request, which tools use to check whether a tag changed, doesn't count. Your real allowance is in the response headers, which you can read without pulling anything:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq -r .token) && curl -s --head -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest | grep -i "^ratelimit"
ratelimit-limit: 100;w=3600 ratelimit-remaining: 100;w=3600

In the lab the header reported a limit of 100 with a window of w=3600 seconds (one hour), not the 21600 seconds the documentation shows. The remaining count depended on what else had pulled from the same address. Trust the header for your address. Shared addresses include offices, CI runners and NAT gateways. There, sign in pulls with a token, or put a pull-through cache or a private mirror in front of Docker Hub. And keep images cached locally instead of pulling on every job.

Clean up:

ubuntu@secopslog-docker:~/lab/registry · Docker 29.9.2
$ docker rm -f lab-registry lab-registry-auth && docker volume rm lab-registry-data && docker image rm localhost:5000/lab-notes:1.0 localhost:5000/lab-notes:1.1 localhost:5000/lab-notes:latest localhost:5001/lab-notes:1.1
lab-registry lab-registry-auth lab-registry-data Untagged: localhost:5000/lab-notes:1.0 Deleted: sha256:1a604be23ec1f8ee8c057a4143d89c540e0b76a8d905dbba404d1134f666ba2a Untagged: localhost:5000/lab-notes:1.1 Untagged: localhost:5000/lab-notes:latest Untagged: localhost:5001/lab-notes:1.1 Deleted: sha256:bdc3c288264f7a68ead49f9a7d4dbde07d089a360b3b71e9e5d92f3781eb17b9

Try this

Before you clean up, point latest back at 1.0 with the same docker tag and docker push -q pair you used above. Repeat the curl -sI ... manifests/latest request and predict which digest it returns. Then compare it with the 1.0 digest that docker image ls --digests showed.

Bottom line

Deploy by version tag or digest, never by a tag someone else can move. One push sent latest to the 1.1 build, and nothing on the pulling side showed the change. Keep registry credentials in a helper rather than ~/.docker/config.json, where base64 -d turned the stored auth value back into the user and password in one command.

Quick check
01Two servers deployed registry.example.com/shop:latest a week apart and now run different code, although nobody changed the deploy scripts. What happened?
Correct — A tag is a pointer that a push can move. The scripts asked for the same name and got whatever it pointed at that day.
Incorrect — A digest names exact content; there is no newer copy of a digest. The tag moved.
Incorrect — Nothing updates latest automatically. It moves only when someone (or something) pushes to it.
Incorrect — Local images are immutable content. The registry tag moved, not the image on the first server.
02After docker login to a private registry on a Linux build server, Docker warns that credentials are stored unencrypted. What is in ~/.docker/config.json?
Incorrect — That is part of Docker Hub's interactive login flow, not what config.json stores for this registry.
Correct — base64 is encoding, not encryption. A credential helper such as pass keeps the secret out of the file.
Incorrect — bcrypt hashes belong in the registry's htpasswd file. The client needs the password itself to send it.
Incorrect — The CLI keeps the credential on disk so later pushes and pulls work without logging in again.
03A CI fleet behind one NAT address starts failing image pulls from Docker Hub with "429 Too Many Requests" in the afternoon. Which fix addresses the cause?
Incorrect — Retries add requests to an address that is already over its limit.
Incorrect — Pulling by digest still fetches the manifest and counts as a pull.
Correct — Signed-in pulls have their own, higher limit, and a cache serves repeat pulls without asking Docker Hub at all.
Incorrect — Anonymous pulls are counted per IP address, and logging out makes every pull anonymous.

Next: Docker in depth

You can now run containers, build images, keep their data, connect them, describe a stack in Compose and move images through a registry. Docker in depth takes each of those apart. You'll see how an image is laid out as an index, manifests and layers, and how BuildKit and Buildx build for several platforms. It covers the engine and its daemon configuration, where Docker stores data on disk, and the packet path behind a published port. It ends with Swarm for running services across more than one host. It assumes everything in this course and nothing more.

Related