Docker – Registries, Tagging and Pushing an Image

August 5, 20264 min readUpdated 8/21/2026

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:latest

That 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.2

docker 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:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d

Pull 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 exists

Layers 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.