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: 4640c470f4d20d8efab4f7c1ff673ae599cdfdccbdb0d9a8b00995d2e5e08eadWhile 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, docker run of an image that was not cached yet, and CI job on that address counted against the same anonymous allowance. Registries are where images come from and where yours go, and how you name, push, pull and authenticate decides both what you run and whether you can get it at all.
This lesson runs a registry of your own in a container, so you can tag, push and pull without an account, then puts a password in front of it to show 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 and is optional on a first pass; its last step removes them again.
What an image name says
A full image reference is registry/namespace/repository:tag. nginx:1.30-alpine is short for docker.io/library/nginx:1.30-alpine: when the first part is not 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 cannot move.
A registry of your own
Build a tiny image, start the official registry:3 image (the CNCF Distribution registry) with a named volume for its storage, and give the image a second name that points at that registry:
FROM alpine:3.22COPY notes.txt /notes.txtCMD ["cat", "/notes.txt"]
docker tag copied nothing. Both names show the same ID (f4e2e99e1c1d), 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 is 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 (the alpine:3.22 base image, for one, was never deleted), so only the missing ones were downloaded. 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 f4e2e99e1c1d. 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: 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 is not 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 is not on the VM; remove it afterwards with docker image rm httpd:2.4-alpine if you like):
$2y$ marks a bcrypt hash. Passing a password on the command line, as this lab step does with a test value, leaves it in shell history and in the process list; 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, so the secret lives in a proper store: the macOS Keychain (osxkeychain), Windows Credential Manager (wincred), Docker Desktop's own helper, or on a Linux server pass, which keeps each secret as a GPG-encrypted file. "credsStore" in config.json sets the helper for every registry; "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 does not 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 are 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, and 200 per 6 hours for signed-in Personal accounts; paid plans are not limited. A pull counts when the manifest is fetched with GET; a HEAD request, which tools use to check whether a tag changed, does not. 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, and the remaining count depended on what else had pulled from the same address. Trust the header for your address. On shared addresses (offices, CI runners, NAT gateways), 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:
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: how an image is laid out as an index, manifests and layers, BuildKit and Buildx builds for several platforms, the engine and its daemon configuration, where Docker stores data on disk, the packet path behind a published port, and Swarm for running services across more than one host. It assumes everything in this course and nothing more.
Try this
Run docker login on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
If you keep one thing from registries, tags and sharing images, keep “Next: Docker in depth”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.