Docker Basics #5: Registries — Docker Hub, GHCR, push/pull

7 min read

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:

What is a registry #

If GitHub is the remote repository for code, a registry is the remote repository for images. The analogy holds.

CodeImage
Creategit commitdocker build
Pushgit pushdocker push
Pullgit pulldocker pull
HostGitHub, GitLabDocker 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.27 live 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.

Full image-name form
[REGISTRY/]NAMESPACE/REPOSITORY[:TAG][@DIGEST]

Examples:

NameExpanded
nginxdocker.io/library/nginx:latest
python:3.14-slimdocker.io/library/python:3.14-slim
myuser/myapp:1.0docker.io/myuser/myapp:1.0
ghcr.io/curtis/myapp:1.0curtis/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.

Add tags to an existing image
docker tag hello-docker myuser/hello-docker:1.0
docker tag hello-docker myuser/hello-docker:latest

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

Tag for GHCR
docker tag hello-docker ghcr.io/curtis/hello-docker:1.0

This 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 Hub login
docker login
# Username: myuser
# Password: ******
# Login Succeeded

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

Tag and 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: 1234

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

Pull from another machine
docker pull myuser/hello-docker:1.0
docker run --rm myuser/hello-docker:1.0

Push 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.)

Stash it somewhere like .env
export CR_PAT=ghp_xxxxxxxxxxxxxxxxxx

2. Log in #

Log into GHCR
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 #

GHCR tag → push
docker tag hello-docker ghcr.io/curtis/hello-docker:1.0
docker push ghcr.io/curtis/hello-docker:1.0

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

.github/workflows/build.yml (excerpt)
- 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).

Pull by digest
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:

See an image's digest
docker inspect --format '{{index .RepoDigests 0}}' myuser/hello-docker:1.0
# myuser/hello-docker@sha256:abcdef...

docker pull — pulling #

Basic pull
docker pull nginx:1.27
docker pull ghcr.io/curtis/hello-docker:1.0

Useful options:

Handy forms
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:

Run a local registry
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.0

Useful 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 push says denied: requested access to the resource is denied — almost always login / permission / tag name. Often the tag was someoneelse/... instead of myuser/....
  • 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 cached latest. docker pull myimage:latest once 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 get docker.io/library/...:latest.
  • docker tag adds an alias; docker push uploads layer by layer.
  • Docker Hub uses a Personal Access Token; GHCR uses a GitHub PAT (or GITHUB_TOKEN inside Actions).
  • Avoid relying on latest in 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.

X