Registries: Docker Hub, GHCR, ECR and tags you can trust
Share images through a registry: name and push them, log in to Docker Hub, GHCR and ECR, and tag so every deploy is traceable. Tags can move; a digest never does, and this lesson proves it.
By the end of this lesson you'll be able to
0 of 5 completeWhat's in a name
An image lives in your local Docker until you push it to a registry, a server that stores images so other machines can pull them. The full name says where:
ghcr.io / acme/price-api : 1.4.0 @sha256:034074b2… registry repository tag digest
Leave out the registry and Docker assumes Docker Hub: nginx:1.30-alpine is really
docker.io/library/nginx:1.30-alpine. To push, you tag the image with the registry's name, then push. Try it
with a registry running on your own machine:
$ docker run -d --name registry -p 127.0.0.1:5000:5000 registry:3 $ docker tag price-api:multi localhost:5000/price-api:1.0.0 $ docker push localhost:5000/price-api:1.0.0 96ed2737ae31: Pushed a5789fc40e82: Pushed b8b68e3af840: Pushed 1.0.0: digest: sha256:034074b20fb92e756118038e8c32abeb0c269da12b78451263de0b83cdef6ccd size: 856 $ docker tag price-api:multi localhost:5000/price-api:sha-3f9c2e1 $ docker push localhost:5000/price-api:sha-3f9c2e1 3214acf345c0: Layer already exists 7c12895b777b: Layer already exists sha-3f9c2e1: digest: sha256:034074b20fb92e756118038e8c32abeb0c269da12b78451263de0b83cdef6ccd size: 856 $ curl -s localhost:5000/v2/price-api/tags/list {"name":"price-api","tags":["1.0.0","sha-3f9c2e1"]}
docker run -d --name registry -p 127.0.0.1:5000:5000 registry:3dockerbuild and run containersrunstart a new container-drun in the background--name registryname the container-p 127.0.0.1:5000:5000publish a port, host:containerregistry:3the image to start
docker tag price-api:multi localhost:5000/price-api:1.0.0taggive an image another nameprice-api:multithe image, then its new namelocalhost:5000/price-api:1.0.0the image, then its new name
docker push localhost:5000/price-api:1.0.0pushupload an image to a registrylocalhost:5000/price-api:1.0.0the image
docker tag price-api:multi localhost:5000/price-api:sha-3f9c2e1localhost:5000/price-api:sha-3f9c2e1the image, then its new name
docker push localhost:5000/price-api:sha-3f9c2e1localhost:5000/price-api:sha-3f9c2e1the image
curl -s localhost:5000/v2/price-api/tags/listcurlmake an HTTP request-ssilent: no progress bar
The second push uploaded nothing: layers are stored by their hash, and the registry already had them. Two tags, one digest.
Tags move, digests don't
A tag is a name that points at an image. A digest is the SHA-256 hash of the image's manifest: change
one byte of content and the digest changes. Watch what happens when a different build is pushed as 1.0.0:
$ docker tag price-api:single localhost:5000/price-api:1.0.0 $ docker push localhost:5000/price-api:1.0.0 1.0.0: digest: sha256:367778fc7a085fc1131a5d04d26bb8ffb0e48d45bde2b5aa67bbd4bae48c7881 size: 856 $ docker pull localhost:5000/price-api@sha256:034074b20fb92e756118038e8c32abeb0c269da12b78451263de0b83cdef6ccd Status: Downloaded newer image for localhost:5000/price-api@sha256:034074b2…
docker tag price-api:single localhost:5000/price-api:1.0.0dockerbuild and run containerstaggive an image another nameprice-api:singlethe image, then its new namelocalhost:5000/price-api:1.0.0the image, then its new name
docker push localhost:5000/price-api:1.0.0pushupload an image to a registrylocalhost:5000/price-api:1.0.0the image
docker pull localhost:5000/price-api@sha256:034074b20fb92e756118038e8c32abeb0c269da12b78451263de0b83cdef6ccdpulldownload an imagelocalhost:5000/price-api@sha256:034074b20fb92e756118038e8c32abeb0c269da12b78451263de0b83cdef6ccdthe image
The registry accepted the overwrite without a word. A server that pulled 1.0.0 yesterday and one that pulls
it today now run different code. The digest still fetches the original. Find an image's digest with
docker buildx imagetools inspect localhost:5000/price-api:sha-3f9c2e1 or in the push output.
Docker Hub
Docker Hub is the default registry and home of the official images. Create a free account, then:
$ docker login -u yourname # prompts for a personal access token $ docker tag price-api:multi yourname/price-api:1.0.0 $ docker push yourname/price-api:1.0.0
docker login -u yournamedockerbuild and run containersloginsign in to a registry
docker tag price-api:multi yourname/price-api:1.0.0taggive an image another nameprice-api:multithe image, then its new nameyourname/price-api:1.0.0the image, then its new name
docker push yourname/price-api:1.0.0pushupload an image to a registryyourname/price-api:1.0.0the image
Use a personal access token from your account settings, not your password, and give it only the scope it needs (read-only for servers that just pull). Docker Hub limits how many images can be pulled per hour, with lower limits for anonymous pulls, so log in on CI runners and servers too; the current limits are in the usage and limits page linked below. Free accounts can keep public repositories, plus a limited number of private ones.
GHCR and ECR
Most teams keep images next to their code or their cloud. GitHub Container Registry (ghcr.io) uses your
GitHub login and permissions:
$ echo "$GHCR_TOKEN" | docker login ghcr.io -u yourname --password-stdin $ docker tag price-api:multi ghcr.io/yourname/price-api:1.0.0 $ docker push ghcr.io/yourname/price-api:1.0.0
echo "$GHCR_TOKEN" | docker login ghcr.io -u yourname --password-stdinechoprint text|pipe: send output to the next commanddockerbuild and run containersloginsign in to a registry
docker tag price-api:multi ghcr.io/yourname/price-api:1.0.0taggive an image another nameprice-api:multithe image, then its new nameghcr.io/yourname/price-api:1.0.0the image, then its new name
docker push ghcr.io/yourname/price-api:1.0.0pushupload an image to a registryghcr.io/yourname/price-api:1.0.0the image
In GitHub Actions, the job's own GITHUB_TOKEN can push, given permissions: packages: write (the CI/CD
course builds this workflow). New packages are private until you change their visibility.
Amazon ECR gives each AWS account a registry, logged in to with a 12-hour token from the AWS CLI:
$ aws ecr create-repository --repository-name price-api --image-tag-mutability IMMUTABLE $ aws ecr get-login-password --region eu-west-1 \ | docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-west-1.amazonaws.com $ docker tag price-api:multi 123456789012.dkr.ecr.eu-west-1.amazonaws.com/price-api:sha-3f9c2e1 $ docker push 123456789012.dkr.ecr.eu-west-1.amazonaws.com/price-api:sha-3f9c2e1
aws ecr create-repository --repository-name price-api --image-tag-mutability IMMUTABLEawsthe AWS command line
aws ecr get-login-password --region eu-west-1 \--regionwhich Region
docker tag price-api:multi 123456789012.dkr.ecr.eu-west-1.amazonaws.com/price-api:sha-3f9c2e1dockerbuild and run containerstaggive an image another nameprice-api:multithe image, then its new name123456789012.dkr.ecr.eu-west-1.amazonaws.com/price-api:sha-3f9c2e1the image, then its new name
docker push 123456789012.dkr.ecr.eu-west-1.amazonaws.com/price-api:sha-3f9c2e1pushupload an image to a registry123456789012.dkr.ecr.eu-west-1.amazonaws.com/price-api:sha-3f9c2e1the image
IMMUTABLE makes ECR reject a push to an existing tag, which turns the overwrite you saw above into an error.
In CI, get AWS credentials through OIDC instead of stored keys (CI/CD course, lesson on secrets).
Tagging best practices
| Tag | Use |
|---|---|
sha-3f9c2e1 (the git commit) | every build: says exactly which code is inside, never reused |
1.4.0, plus 1.4 and 1 | releases; the short ones move on purpose to the newest patch |
main, pr-42 | the latest build of a branch or pull request, for test environments |
latest | convenience only: never deploy it, never pin it |
Deploy by digest, or by the SHA tag, never by a tag that moves. Then you can always say what's running, and a rollback means pointing back at an earlier digest that is still in the registry. Set a cleanup policy too: registries keep every untagged layer until you delete it, and storage costs money.
Prove a tag can lie
Run a private registry on your machine, push one image under a version tag and a commit tag, then push a different image under the same version tag. Show the digests change, and pull the original back by digest.
Two different digests for one tag, and a successful pull by the original digestReveal one safe solution
$ docker run -d --name registry -p 127.0.0.1:5000:5000 registry:3 $ docker tag price-api:multi localhost:5000/price-api:1.0.0 $ docker push localhost:5000/price-api:1.0.0 | tail -1 # note the digest $ docker tag price-api:multi localhost:5000/price-api:sha-3f9c2e1 $ docker push localhost:5000/price-api:sha-3f9c2e1 | grep -c "already exists" $ docker tag price-api:single localhost:5000/price-api:1.0.0 $ docker push localhost:5000/price-api:1.0.0 | tail -1 # a different digest $ docker pull localhost:5000/price-api@sha256:<the first digest> $ docker rm -f registry
docker run -d --name registry -p 127.0.0.1:5000:5000 registry:3dockerbuild and run containersrunstart a new container-drun in the background--name registryname the container-p 127.0.0.1:5000:5000publish a port, host:containerregistry:3the image to start
docker tag price-api:multi localhost:5000/price-api:1.0.0taggive an image another nameprice-api:multithe image, then its new namelocalhost:5000/price-api:1.0.0the image, then its new name
docker push localhost:5000/price-api:1.0.0 | tail -1pushupload an image to a registrylocalhost:5000/price-api:1.0.0the image|pipe: send output to the next commandtailshow the last lines
docker tag price-api:multi localhost:5000/price-api:sha-3f9c2e1localhost:5000/price-api:sha-3f9c2e1the image, then its new name
docker push localhost:5000/price-api:sha-3f9c2e1 | grep -c "already exists"localhost:5000/price-api:sha-3f9c2e1the imagegrepfind lines that match a pattern-ccount matching lines
docker tag price-api:single localhost:5000/price-api:1.0.0price-api:singlethe image, then its new name
docker pull localhost:5000/price-api@sha256:<the first digest>pulldownload an imagelocalhost:5000/price-api@sha256:<thethe imagefirstthe imagedigest>the image
docker rm -f registryrmdelete a container-fstop it first if runningregistrythe container
Same tag, two digests: the first push's content is still there, reachable only by its digest or by the
sha-3f9c2e1 tag. That's why deploys pin one of those.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
In ghcr.io/acme/price-api:1.4.0, what is ghcr.io?
Official reading for this lesson
Or get every quiz answer right and it completes itself.