Every container you have run came from an image, and every image came from somewhere. This lesson is about that somewhere, and about the naming scheme that confuses people for longer than it should.
A full image reference has four parts, and you almost never write all of them:
docker.io/library/nginx:1.29-alpine
└──┬───┘ └──┬────┘ └─┬─┘ └────┬────┘
registry namespace name tag
Omit the registry and you get Docker Hub. Omit the namespace on Docker Hub and you get library, the official-images namespace. Omit the tag and you get latest.
So nginx and docker.io/library/nginx:latest are the same thing. And ghcr.io/myorg/myapp:v2 is a different registry entirely — the registry is just a hostname, and every cloud provider runs one.
This one is a recording — the commands are real, but nothing is running here. Type them into your own shell to follow along.
latest is not the latestThis is the naming trap, and it costs people real time.
latest is not a version. It is not resolved at pull time to whatever is newest. It is just the default tag — the one used when you do not name one — and it points at whatever the publisher last pushed under that name. Which may be months old, or a release candidate, or an entirely different major version than last week.
FROM node:latest # unpredictable: could change under you tomorrow
FROM node:22 # better: pinned to a major line, still gets patches
FROM node:22.14-alpine # better still for reproducibility
FROM node@sha256:9f2a… # exact bytes, immutable, what production wants
Tags are mutable pointers. A publisher can move node:22 to new bytes at any time, and most do — that is how you get security patches. A digest (@sha256:…) is the content hash and cannot move. The Advanced course argues for digests in production; for now, the habit worth forming is simply never writing latest in a Dockerfile.
Your build worked yesterday and fails today. Nothing in your repository changed. The Dockerfile starts FROM python:latest. What is the likely cause?
docker image ls shows nowDocker Engine 29 changed this command's default output. It now uses what was previously behind --tree, and it no longer lists untagged images unless you pass --all.
docker image ls # tagged images, tree view
docker image ls --all # including untagged intermediate layers
docker image ls --tree # explicit, same as the default now
If you learned Docker before this and remember a flat table full of <none> entries, that is what changed. The <none> images did not go anywhere; they are just no longer the first thing you see.
docker build -t myapp:1.0 .
docker tag myapp:1.0 myapp:latest
docker tag myapp:1.0 ghcr.io/myorg/myapp:1.0
docker tag does not copy anything. It adds a second name pointing at the same image ID — like a hard link. Which is why re-tagging is instant regardless of image size, and why deleting one tag does not delete the image if another still points at it.
To push, the tag must contain the destination registry. That is why the third line above exists: you cannot push myapp:1.0 anywhere, because that name says Docker Hub's library namespace, which you do not own.
This one is a recording — the commands are real, but nothing is running here. Type them into your own shell to follow along.
Give the existing image myapp:1.0 a second name so it can be pushed to ghcr.io/acme/myapp as version 1.0.
The base image decides most of your final size, most of your CVE count, and which debugging tools you have at 3am. The usual ladder:
| Base | Size | Trade-off |
|---|---|---|
ubuntu, debian | 70–120 MB | everything works, everything is present, largest surface |
*-slim | 25–80 MB | same distro, docs and extras stripped |
alpine | 5–15 MB | musl libc, not glibc — some binaries and wheels break |
| distroless | 2–20 MB | no shell, no package manager; hard to debug, hard to exploit |
scratch | 0 | nothing at all; static binaries only |
Alpine's catch is worth stating because it costs people an afternoon: it uses musl rather than glibc. Most things are fine; Python packages with compiled wheels, some Node native modules, and anything shipping a glibc-linked binary are not, and the failure is usually a confusing "not found" for a file that plainly exists.
A Python app runs on python:3.13 but crashes on python:3.13-alpine with errors about missing shared libraries. Why?
Docker Hub applies pull-rate limits to anonymous and free accounts, counted per IP. Which is fine on a laptop and not fine on shared CI, where every job on the runner shares one address and the pool exhausts before lunch.
Symptoms: toomanyrequests: You have reached your pull rate limit, usually appearing suddenly on a pipeline that was fine last week because the team grew.
The fixes, in ascending order of effort:
mcr.microsoft.com, public.ecr.aws, quay.io, ghcr.io.registry:3 image can proxy Docker Hub, so your builders pull once and serve locally after that.The daemon also takes a registry-mirrors setting in /etc/docker/daemon.json, which redirects Hub pulls without touching a single image name in your Dockerfiles.
Next up: writing a Dockerfile — turning your own code into one of these things.
