Publishing an Image to a Registry
Tagging, logging in and pushing to Docker Hub or GitHub Container Registry, plus what a tag really is and why :latest is the one you should not depend on.
Tagging, logging in and pushing to Docker Hub or GitHub Container Registry, plus what a tag really is and why :latest is the one you should not depend on.
Everything you have built so far exists on exactly one machine. To run it anywhere else — a colleague's laptop, a CI runner, a server — that image has to be somewhere both machines can reach.
That somewhere is a registry, and you have been pulling from one
since your first command. By the end of this lesson you will have
pushed an image of your own to Docker Hub or GitHub Container
Registry, you will know what a tag actually commits you to, and you
will know why :latest is the tag not to depend on.
A registry is a server that stores images and hands them out. It is the same relationship a package manager has with its index: you push a built artefact up, and any number of machines pull it down.
The transfer works in layers, which is what makes it practical. A push sends only the layers the registry does not already have, and a pull fetches only the layers the machine is missing. Update your application code and the push is a few hundred kilobytes, because the base image and your dependencies are already there.
Registries you will meet:
| Registry | Prefix | Notes |
|---|---|---|
| Docker Hub | none needed | The default. Free public, limited private. |
| GitHub | ghcr.io/ | Free for public, tied to your repository. |
| GitLab | registry.gitlab.com/ | Built into GitLab projects. |
| AWS ECR | <id>.dkr.ecr.<region>.amazonaws.com/ | Private, IAM auth. |
| Self-hosted | your hostname | The registry image, run yourself. |
The commands are identical for all of them. Only the name of the image changes, because the registry is part of the name.
This is the piece worth getting straight before pushing anything, because a push goes wherever the name says.
ghcr.io/antonii-devfoxlabs/hello-docker:0.1.0
^^^^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^
registry namespace name tagghcr.io — the registry
Which server. Omitted means docker.io, which is why nginx
works with no prefix at all.
antonii-devfoxlabs — the namespace
Whose account. Omitted means library, the namespace for
official images — which is not yours, and is the reason a
first push is usually denied.
hello-docker — the name
Which repository within that account.
0.1.0 — the tag
Which version. Omitted means latest, which is a name and not
a promise.
Docker fills in the defaults when you leave them out, which is why
nginx works and why your first push attempt fails:
That last line is the error people hit first. An image tagged
hello-docker resolves to the library namespace on Docker Hub,
which holds official images and does not belong to you:
The image has to carry your namespace before it can be pushed anywhere. That is what re-tagging is for.
A tag is a label pointing at an image, and an image can have several.
docker tag adds one without rebuilding anything:
Three rows, one IMAGE ID, 143 MB of disk. They are three names for
one image, not three copies — which is also why deleting one tag does
not delete the image while another tag still points at it.
Or tag at build time and skip the step:
For Docker Hub, create an account, then create an access token in the account settings rather than using your password:
Layer already exists on the base layers is the sharing at work.
Only your own layers travel.
For GitHub Container Registry, authenticate with a personal access
token that has write:packages:
--password-stdin is the form to use habitually. A token typed as a
command argument lands in your shell history and in the process list
where other users on the machine can read it.
Then confirm it is genuinely there, from a machine that never built it:
Deleting the local copy first is the only honest test. Otherwise Docker uses what it already has and tells you nothing.
A tag is mutable. Nothing stops you pushing a different image to a tag you have already published, and that is the source of the one real mistake in this lesson.
Bad — every deployment gets whatever was pushed most recently:
Good — the version is decided once, and the server asks for that version:
With :latest nobody can say which code is running, two servers
pulling minutes apart can be on different builds, and rolling back
means rebuilding an old commit and hoping it produces the same image.
With a version tag, "roll back to 1.4.1" is a pull, and the tag in
your deployment configuration is a written record of what is
deployed.
The convention worth adopting: push a specific, immutable tag for
every build, and move :latest as a convenience for humans who just
want to try the thing.
A common alternative in CI is the commit SHA — myapp:8f3c1a9 —
which is unambiguous, ties the image to exactly one commit, and
needs no version decision at build time.
Three ways to name what you deploy, and they give three different strengths of promise.
A digest is the content itself. Not "the image called 1.4.2"
but "these exact bytes", whatever anyone does to the tags
afterwards. Take it from docker inspect or the push output.
Almost always what you want. It can be repointed, and by convention nobody does.
Whatever was pushed most recently. Two servers pulling minutes apart can end up on different builds.
A repository on Docker Hub is public by default, and a public image is public in every sense — anyone can pull it, and anyone can read every file in every layer.
So before a first push, two checks:
Read it for secrets. An ENV API_KEY=... or an ARG carrying a
token appears here in plain text. This is the moment that mistake
becomes public rather than merely present.
Read that for files that should not have travelled — a .env, a
private key, a database dump your .dockerignore missed.
Both take ten seconds and are much cheaper than rotating a credential.
If you build on an Apple Silicon Mac and deploy to an ordinary
cloud server, you have built for arm64 and the server needs
amd64. The failure is not obvious:
Build for both at once with buildx, which is included with Docker:
Note --push rather than a separate command: a multi-platform build
produces a manifest listing one image per architecture, which cannot
be stored in your local image list. It goes straight to the
registry, and a machine pulling it gets the variant matching its own
architecture automatically.
To build for a single other platform, one flag is enough:
Push something small and public, then pull it on another machine or ask a colleague to. Watching your own image arrive somewhere it was never built is the moment the whole model pays off.
The last lesson of the course is the capstone: one application, taken from bare source to a published, hardened, configured image running behind Compose with persistent data — every idea from these thirteen lessons in a single pass, in the order you would actually do it.
nginx
-> docker.io/library/nginx:latest
myname/hello-docker
-> docker.io/myname/hello-docker:latest
hello-docker
-> docker.io/library/hello-docker:latest # not yours to push todenied: requested access to the resource is deniedREPOSITORY TAG IMAGE ID SIZE
hello-docker 0.1.0 7f2a91c4e8b3 143MB
myname/hello-docker 0.1.0 7f2a91c4e8b3 143MB
myname/hello-docker latest 7f2a91c4e8b3 143MBThe push refers to repository [docker.io/myname/hello-docker]
5f70bf18a086: Layer already exists
a1b2c3d4e5f6: Pushed
0.1.0: digest: sha256:9d2c8f... size: 1573exec /server: exec format errordocker tag hello-docker:0.1.0 myname/hello-docker:0.1.0
docker tag hello-docker:0.1.0 myname/hello-docker:latestdocker images | grep hello-dockerdocker build -t myname/hello-docker:0.1.0 \
-t myname/hello-docker:latest .docker login -u myname
# Password: paste the access tokendocker push myname/hello-docker:0.1.0echo "$GITHUB_TOKEN" | docker login ghcr.io -u antonii-devfoxlabs \
--password-stdin
docker tag hello-docker:0.1.0 \
ghcr.io/antonii-devfoxlabs/hello-docker:0.1.0
docker push ghcr.io/antonii-devfoxlabs/hello-docker:0.1.0docker rmi myname/hello-docker:0.1.0
docker run --rm -p 8000:8000 myname/hello-docker:0.1.0docker build -t myname/hello-docker:latest .
docker push myname/hello-docker:latest
# on the server
docker pull myname/hello-docker:latest && docker compose up -ddocker build -t myname/hello-docker:1.4.2 .
docker push myname/hello-docker:1.4.2
# on the server
docker pull myname/hello-docker:1.4.2 && docker compose up -ddocker build \
-t myname/hello-docker:1.4.2 \
-t myname/hello-docker:1.4 \
-t myname/hello-docker:latest .docker history --no-trunc myname/hello-docker:0.1.0docker run --rm --entrypoint sh myname/hello-docker:0.1.0 \
-c "ls -la /app"docker buildx create --use --name multi
docker buildx build --platform linux/amd64,linux/arm64 \
-t myname/hello-docker:1.4.2 --push .docker build --platform linux/amd64 -t myname/hello-docker:1.4.2 .# The full name decides where a push goes
# registry / namespace / name : tag
# ghcr.io/antonii-devfoxlabs/hello-docker:0.1.0
# nginx == docker.io/library/nginx:latest
# Tagging
docker tag hello-docker:0.1.0 myname/hello-docker:0.1.0
docker build -t myname/hello-docker:1.4.2 \
-t myname/hello-docker:latest .
# Logging in (a token, never a password, never as an argument)
docker login -u myname
echo "$TOKEN" | docker login ghcr.io -u user --password-stdin
docker logout
# Pushing and pulling
docker push myname/hello-docker:1.4.2
docker pull myname/hello-docker:1.4.2
docker pull myname/hello-docker@sha256:9d2c8f... # exact bytes
# Before a first public push
docker history --no-trunc myname/hello-docker:1.4.2 # secrets?
docker run --rm --entrypoint sh myname/hello-docker:1.4.2 \
-c "ls -la /app" # stray files?
# Proving the push worked
docker rmi myname/hello-docker:1.4.2
docker run --rm myname/hello-docker:1.4.2
# More than one architecture
docker build --platform linux/amd64 -t myname/app:1.4.2 .
docker buildx build --platform linux/amd64,linux/arm64 \
-t myname/app:1.4.2 --push .