An image on your laptop is not a deployment. A registry is the thing in between — a server that stores images and hands them out by name.
What a reference is made of
Every image name is the same four parts, most of them usually left out:
ghcr.io/folaukaveinga/pizza/pizza-api:1.4.2
└─ registry ─┘└──── namespace + repository ────┘└ tag ┘
nginx
→ docker.io/library/nginx:latestThat expansion is why docker pull nginx works and why a plain
docker push pizza-api does not: with no registry in the name it tries Docker Hub, under
a namespace you do not own.
So pushing is always two steps — retag with a full reference, then push:
docker tag pizza-api:dev ghcr.io/folaukaveinga/pizza/pizza-api:1.4.2
docker push ghcr.io/folaukaveinga/pizza/pizza-api:1.4.2docker tag copies nothing. It adds a second name for the same image ID.
Logging in
echo "$GITHUB_TOKEN" | docker login ghcr.io -u folaukaveinga --password-stdin--password-stdin, not -p. A password on the command
line is in your shell history and in the process list, where anyone on the machine can read it with
ps. Docker prints a warning about this and it is worth heeding.
⚠️ docker login writes the credential to ~/.docker/config.json, and on
Linux with no credential helper configured that is base64, not encryption. Install
docker-credential-pass or the equivalent on any shared machine.
Which registry
Docker Hub is the default and where public base images live. Free public repositories, one free private one, and rate limits on anonymous pulls — which is the detail that catches people, because a CI pipeline pulling from a shared NAT address hits them and the error blames your build.
GHCR (ghcr.io) inherits your repository's permissions and
authenticates with the GITHUB_TOKEN that GitHub Actions mints for each run — so there
is no long-lived credential to store. That is what the workflow in lesson 20 uses.
ECR, Artifact Registry, ACR — the cloud providers' own. Use the one your compute is in: the pull is on the internal network, and IAM does the authentication so there is no password anywhere.
ECR has one wrinkle worth knowing in advance: a repository must exist before you can push
to it. Every other registry creates one on first push; ECR returns
name unknown.
Tagging that survives a rollback
Here is the part that actually matters, and it is a design decision rather than a command.
:latest is a mutable pointer, not a version. Nothing about the name
tells you what is in it, and it moves.
Deploy :latest and you get three problems. Two servers pulling a minute apart can
run different code. Nobody can answer "what is running in production?" — :latest is not
an answer. And there is nothing to roll back to, because the previous image no longer has a
name.
What works is several tags on the same image, each answering a different question:
ghcr.io/…/pizza-api:sha-a3f9c21 ← immutable. THIS is what you deploy.
ghcr.io/…/pizza-api:1.4.2 ← the release humans talk about
ghcr.io/…/pizza-api:1.4 ← "latest patch of 1.4", for a base image
ghcr.io/…/pizza-api:main ← whatever is on main right now
ghcr.io/…/pizza-api:latest ← for a human typing `docker run`Derive them from the git ref rather than typing them, so they cannot be wrong:
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}The rule underneath all of it: deploy an immutable tag, and keep the moving ones for
humans. A rollback is then re-deploying a sha- tag that is still sitting in
the registry.
Digests, when it has to be exact
Even an immutable-by-convention tag is only convention. A digest is content:
$ docker image inspect --format '{{index .RepoDigests 0}}' alpine:3.21
alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07dPull that and you get exactly those bytes or an error. Registries can enforce it for you — ECR has tag immutability as a repository setting, which is one checkbox and removes a whole class of "someone repushed the tag" incident.
Pushing sends less than you think
$ docker push ghcr.io/…/pizza-api:1.4.2
5f70bf18a086: Layer already exists
a1b2c3d4e5f6: Pushed
9f8e7d6c5b4a: Layer already existsLayers are content-addressed (lesson 3), so the registry only accepts what it does not already
have. This is the payoff for lesson 7's layer ordering: the 120 MB dependency layer is pushed once
and then says Layer already exists forever, while a code change pushes a 336 kB
application layer.
Get the ordering wrong and every deploy pushes and pulls the whole thing.
Housekeeping
A sha- tag per commit adds up fast, and storage is billed. Set a retention policy
early — ECR lifecycle rules, GHCR package retention — rather than discovering the bill.
⚠️ Two things to be careful of when you do. Retention that counts images can delete one that a running deployment would need to restart from; keep untagged images longer than you think. And a base image other builds reference must not be pruned out from under them.
Next: why the image that works on your laptop may not start on the server.