Registries, tags and sharing images
Tag, push and pull, log in safely, and run a local registry.
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: df0ab7a9b46b1034e793a62ff16e545df4646aa61c5edcb8ec3a16b4b313bd81While 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:
FROM alpine:3.24COPY notes.txt /notes.txtCMD ["cat", "/notes.txt"]
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.
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:
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.
Tags move, digests do not
Push the same image as latest, then build 1.1 and move latest to it:
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:
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:
$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:
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.
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:
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:
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:
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.
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 storeprintf '%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:
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:
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.
registry.example.com/shop:latest a week apart and now run different code, although nobody changed the deploy scripts. What happened?docker login to a private registry on a Linux build server, Docker warns that credentials are stored unencrypted. What is in ~/.docker/config.json?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.