Docker Basics #5: Registries — Docker Hub, GHCR, push/pull
Up through #3, the images we built only existed on our own machine. This post is about making them usable on other machines — colleagues, CI servers, production servers. That’s what a registry is for.
This post in the Docker Basics series:
- #1 What is a container
- #2 Writing your first Dockerfile
- #3 Images and containers
- #4 Volumes and networks
- #5 Registries — Docker Hub, GHCR, push/pull ← this post
- #6
.dockerignoreand the build context
What is a registry #
If GitHub is the remote repository for code, a registry is the remote repository for images. The analogy holds.
| Code | Image | |
|---|---|---|
| Create | git commit | docker build |
| Push | git push | docker push |
| Pull | git pull | docker pull |
| Host | GitHub, GitLab | Docker Hub, GHCR, ECR |
There are many registries, but you’ll usually meet two first:
- Docker Hub — Docker’s official registry. Official images like
python:3.14,nginx:1.27live here. A free account gets you public repos. - GitHub Container Registry (GHCR) — operated by GitHub. Shares permissions with GitHub repos and pairs nicely with GitHub Actions, so it’s increasingly common.
There are also cloud-provider registries (AWS ECR, Google Artifact Registry, Azure Container Registry). From Docker’s point of view they all follow the same OCI protocol — only the login command differs; the push/pull flow is identical.
The structure of an image name #
Worth unpacking how Docker reads image names. It’s a frequent source of confusion.
[REGISTRY/]NAMESPACE/REPOSITORY[:TAG][@DIGEST]Examples:
| Name | Expanded |
|---|---|
nginx | docker.io/library/nginx:latest |
python:3.14-slim | docker.io/library/python:3.14-slim |
myuser/myapp:1.0 | docker.io/myuser/myapp:1.0 |
ghcr.io/curtis/myapp:1.0 | curtis/myapp 1.0 on GHCR |
ghcr.io/curtis/myapp@sha256:abc... | pinned by digest, not tag |
If you omit the registry, Docker assumes docker.io (= Docker Hub). Omit the namespace and it assumes library (the official-image namespace). Omit the tag and it assumes latest. So plain nginx is shorthand for docker.io/library/nginx:latest.
The latest trap
#
latest doesn’t mean “newest.” It’s just the default name when you don’t pass a tag. If someone builds 1.27 without also tagging it latest, latest may still point to an older image. So don’t depend on latest in production — use explicit version tags.
A common tag strategy:
- Semantic version:
myapp:1.2.3 - Major / minor aliases:
myapp:1.2,myapp:1 - Environment:
myapp:staging,myapp:prod - Git commit SHA:
myapp:a1b2c3d - Optionally:
myapp:latest
If CI stamps myapp:1.2.3, myapp:1.2, myapp:1, and myapp:latest from one build, downstream users have flexibility.
docker tag — add an alias
#
Building gives an image one name with -t, but you can add more aliases to the same image.
docker tag hello-docker myuser/hello-docker:1.0
docker tag hello-docker myuser/hello-docker:latestdocker tag doesn’t copy data — it just adds a pointer — so it’s instantaneous. docker images will show multiple names against the same IMAGE ID.
To push to a registry, you usually retag with the registry host and your username included.
docker tag hello-docker ghcr.io/curtis/hello-docker:1.0This tells Docker where to send it. Plain hello-docker can’t be pushed.
Push to Docker Hub #
Make a Docker Hub account at hub.docker.com, then log in from your terminal:
docker login
# Username: myuser
# Password: ******
# Login SucceededUse a Personal Access Token. Docker Hub recommends a PAT instead of your password. Account Settings → Security → New Access Token. Paste the PAT into the password prompt. PATs are also the standard in CI.
Login info is stored in ~/.docker/config.json. Docker Desktop on macOS / Windows uses the OS keychain for stronger storage.
Now push:
docker tag hello-docker myuser/hello-docker:1.0
docker push myuser/hello-docker:1.0
# The push refers to repository [docker.io/myuser/hello-docker]
# 5e7d4abc...: Pushed
# 1.0: digest: sha256:abcdef... size: 1234Pushes happen layer by layer. Layers already on the registry (e.g., the base layers from python:3.14-slim) aren’t re-uploaded — only changed layers go up. That’s why subsequent pushes are fast.
Pull on another machine:
docker pull myuser/hello-docker:1.0
docker run --rm myuser/hello-docker:1.0Push to GitHub Container Registry (GHCR) #
GHCR uses GitHub’s permission model and authenticates with a GitHub PAT.
1. Create a PAT #
GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic). Create a new token with write:packages scope. (Add read:packages if you’ll also pull private images.)
export CR_PAT=ghp_xxxxxxxxxxxxxxxxxx2. Log in #
echo $CR_PAT | docker login ghcr.io -u curtis --password-stdin
# Login Succeeded--password-stdin reads the token from stdin so it doesn’t end up in shell history. CI uses the same pattern.
3. Tag + push #
docker tag hello-docker ghcr.io/curtis/hello-docker:1.0
docker push ghcr.io/curtis/hello-docker:1.0Right after pushing, the package on GHCR is private by default. To make it public, go to GitHub’s package page → “Change package visibility” → public.
From GitHub Actions #
Inside GitHub Actions you can use the auto-issued GITHUB_TOKEN instead of a PAT:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository_owner }}/myapp:${{ github.sha }}Clean — no PAT to manage manually. (Covered in depth in the Docker in Practice series.)
Digests — the most precise pin #
Tags are human-friendly but not immutable. Someone can re-push myapp:1.2.3 with a different image. When precise reproducibility matters, use a digest (SHA-256).
docker pull python@sha256:abc123def456...When you push, the output includes digest: sha256:... — that’s the permanent identifier for that image. The same digest will always point to the same image. In production, security scans, and compliance contexts, pinning by digest is the standard.
Get the digest with docker inspect:
docker inspect --format '{{index .RepoDigests 0}}' myuser/hello-docker:1.0
# myuser/hello-docker@sha256:abcdef...docker pull — pulling
#
docker pull nginx:1.27
docker pull ghcr.io/curtis/hello-docker:1.0Useful options:
docker pull --platform linux/amd64 myimage # specific architecture only
docker pull -a myuser/myapp # all tags--platform is common on Apple Silicon when you want to force-pull the amd64 image — for instance to build/test locally for an amd64 production server. (Multi-arch builds belong to Docker Advanced.)
Private registries — one paragraph #
It’s common to run an internal registry inside a company. Docker provides a containerized registry image:
docker run -d --restart unless-stopped \
-p 5000:5000 --name registry \
-v registry-data:/var/lib/registry \
registry:2
# push
docker tag myapp localhost:5000/myapp:1.0
docker push localhost:5000/myapp:1.0Useful for self-hosting in a lab or internal network. In production, most teams use a managed / full-featured registry like GHCR / ECR / Harbor instead. Just know the option exists.
Common pitfalls #
docker pushsaysdenied: requested access to the resource is denied— almost always login / permission / tag name. Often the tag wassomeoneelse/...instead ofmyuser/....- Push worked but pull fails on another machine — the package is private and the other machine isn’t logged in. Common with GHCR since private is the default.
- An image built on macOS won’t run on the production server (amd64) — you pushed an arm64 image from Apple Silicon. Build with
--platform linux/amd64, or use Buildx for multi-arch. latest“isn’t updating” — Docker on that machine is using a cachedlatest.docker pull myimage:latestonce and you’re set.
Wrap-up #
The picture from this post:
- A registry is the remote repository for images. The GitHub ↔ Docker Hub / GHCR analogy holds directly.
- Image name =
[REGISTRY/]NAMESPACE/REPO[:TAG][@DIGEST]. Omit pieces and you getdocker.io/library/...:latest. docker tagadds an alias;docker pushuploads layer by layer.- Docker Hub uses a Personal Access Token; GHCR uses a GitHub PAT (or
GITHUB_TOKENinside Actions). - Avoid relying on
latestin production — combine semver tags with a Git SHA. - For maximum precision, pin by digest (
@sha256:...).
In the next post (#6 .dockerignore and the build context), we tackle the most common reasons images get bloated or builds get slow — what the build context actually is, how to write a .dockerignore, and the layer cache we briefly hit in #2, now in earnest.