Module 5: Docker in CI/CD
4. Container Registries — GHCR and Docker Hub
Overview
Your Docker image gets built automatically in CI. With a cache, it takes under 2 minutes. Now you need a place to store it — a container registry. Without a registry, the image exists only on the CI runner (which gets destroyed when the job ends) and isn't available for deployment or for other team members.
A container registry is a store of Docker images. It works like GitHub does for code, but for images: you push versions, you tag them, and you download them when you need them. The two most common registries for projects on GitHub are GitHub Container Registry (GHCR) and Docker Hub.
The direct recommendation: If your code is on GitHub and you use GitHub Actions, GHCR is the natural option. Authentication with GITHUB_TOKEN (zero extra configuration), the same ecosystem, permissions integrated with the repo. Docker Hub is the alternative when you need public images with maximum visibility or when your organization already has infrastructure on Docker Hub.
This capsule teaches you to configure both registries in GitHub Actions, to push and pull images, and to decide which one to use based on your context.
GHCR: GitHub Container Registry
What it is
GHCR is GitHub's container registry. It's integrated with GitHub Packages and lets you store Docker images directly associated with your repository or your organization. The images appear in your repo's "Packages" tab.
The key advantage: GITHUB_TOKEN
In GitHub Actions, every workflow run has an automatic GITHUB_TOKEN — a temporary token the runner generates at the start. This token can authenticate against GHCR without you configuring any secret:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
You don't need to create a Personal Access Token. You don't need to configure secrets in the repo. The GITHUB_TOKEN already exists. You only need to declare the right permissions.
The necessary permissions
The GITHUB_TOKEN has default permissions that vary depending on the repo's configuration. To push to GHCR, you need packages: write:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read # Read the repo's code
packages: write # Write images to GHCR
Without packages: write, the push fails with:
denied: installation not allowed to Write organization package
The complete workflow: Build and Push to GHCR
# .github/workflows/docker.yml
name: Docker Build & Push to GHCR
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The tag format in GHCR
ghcr.io/<owner>/<repo>:<tag>
Examples:
ghcr.io/mikenieva/ai-api:sha-abc1234
ghcr.io/mikenieva/ai-api:v1.0.0
ghcr.io/mikenieva/ai-api:latest
ghcr.io/my-org/ai-service:main
The ${{ github.repository }} automatically expands to owner/repo in lowercase. GHCR requires the package's name to be lowercase:
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
# It expands to: ghcr.io/mikenieva/ai-api:abc123def456...
If your repo has uppercase letters in the name, you need to convert to lowercase:
- name: Repository name to lowercase
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ steps.repo.outputs.name }}:${{ github.sha }}
Pulling images from GHCR
From any machine with Docker:
# Login to GHCR
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
# Pull the image
docker pull ghcr.io/mikenieva/ai-api:sha-abc1234
# Run
docker run -p 8000:8000 ghcr.io/mikenieva/ai-api:sha-abc1234
For public images, you don't need a login:
docker pull ghcr.io/mikenieva/ai-api:latest
Package visibility
By default, packages in GHCR inherit the repo's visibility:
- 📋 A public repo → Public packages (anyone can pull)
- 📋 A private repo → Private packages (they need authentication to pull)
You can change the visibility in GitHub → Packages → Package Settings.
Docker Hub
What it is
Docker Hub is Docker's original registry. It's the default when you do docker pull ubuntu — Docker searches Docker Hub automatically. It has the largest ecosystem of public images and it's the natural option if you want maximum visibility for your images.
Configuring the secrets
Unlike GHCR, Docker Hub requires credentials configured as secrets in your repo:
Step 1: Create an Access Token in Docker Hub
Docker Hub → Account Settings → Security → New Access Token
Token description: "GitHub Actions CI"
Access permissions: Read, Write
→ Generate → Copy the token
Step 2: Add the secrets on GitHub
GitHub → Your repo → Settings → Secrets and variables → Actions
→ New repository secret
Name: DOCKERHUB_USERNAME
Value: your-dockerhub-username
Name: DOCKERHUB_TOKEN
Value: dckr_pat_xxxxxxxxxxxxx
Step 3: Use the secrets in the workflow
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
The complete workflow: Build and Push to Docker Hub
# .github/workflows/docker.yml
name: Docker Build & Push to Docker Hub
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ai-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The tag format in Docker Hub
<username>/<image>:<tag>
Examples:
mikenieva/ai-api:sha-abc1234
mikenieva/ai-api:v1.0.0
mikenieva/ai-api:latest
myorg/ai-service:main
Without the ghcr.io/ prefix, Docker assumes Docker Hub by default.
Pulling images from Docker Hub
# Public images — no login
docker pull mikenieva/ai-api:latest
# Private images — with a login
docker login -u mikenieva
docker pull mikenieva/ai-api:sha-abc1234
Comparison: GHCR vs Docker Hub
| Aspect | GHCR | Docker Hub |
|---|---|---|
| Authentication in Actions | GITHUB_TOKEN (automatic) | Manual secrets (username + token) |
| Extra config | Only permissions: packages: write | Create a token, add 2 secrets |
| Ecosystem | Integrated with GitHub | Independent |
| Public images | Unlimited | Unlimited |
| Private images | Depends on the GitHub plan | 1 free, then paid |
| Rate limits (pull) | Generous for authenticated users | 100 pulls/6h (anonymous), 200 (authenticated) |
| Visibility/discovery | Linked to the repo | Global search on Docker Hub |
| OCI compliance | ✅ | ✅ |
| The tag's URL | ghcr.io/owner/repo:tag | owner/repo:tag |
| Ideal for | Projects on GitHub, teams on GitHub | Public images, the Docker ecosystem |
When to choose each one
Is your code on GitHub and do you use GitHub Actions?
→ GHCR — zero secret configuration, the same ecosystem
Do you publish public images you want in Docker's hub?
→ Docker Hub — maximum visibility and discovery
Does your organization already have Docker Hub Enterprise?
→ Docker Hub — keep it consistent
Do you want the simplest possible option?
→ GHCR — the GITHUB_TOKEN already exists, you only need permissions
Do you need both?
→ You can push to both in the same workflow
Pushing to both registries
If you need to push to GHCR and Docker Hub at the same time:
name: Docker Build & Push (Dual Registry)
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push to both
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
${{ secrets.DOCKERHUB_USERNAME }}/ai-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The multi-line tags tells the action to push the same image to both registries. The image gets built only once but pushed to two destinations.
Conditional push: only on main
In most workflows, you want to build on every PR (to validate) but only push when the merge reaches main:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- On a PR:
push: false,load: true→ it builds and loads locally (for tests) - On a push to main:
push: true,load: false→ it builds and pushes to the registry
Alternatively, make the login conditional:
- name: Login to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Verifying that the image is in the registry
GHCR
# With the GitHub CLI
gh api user/packages/container/ai-api/versions --jq '.[0].metadata.container.tags'
# With Docker
docker pull ghcr.io/mikenieva/ai-api:sha-abc1234
docker inspect ghcr.io/mikenieva/ai-api:sha-abc1234 --format '{{.RepoDigests}}'
You can also verify it in GitHub's UI: your repo → Packages → select the package → the list of versions.
Docker Hub
# With the Docker CLI
docker pull mikenieva/ai-api:sha-abc1234
docker inspect mikenieva/ai-api:sha-abc1234 --format '{{.RepoDigests}}'
# With the API
curl -s "https://hub.docker.com/v2/repositories/mikenieva/ai-api/tags/" | python -m json.tool
Comparisons
The authentication setup
| Step | GHCR | Docker Hub |
|---|---|---|
| 1 | Add permissions: packages: write to the job | Create an Access Token in Docker Hub |
| 2 | Use docker/login-action with GITHUB_TOKEN | Add DOCKERHUB_USERNAME as a secret |
| 3 | ✅ Done | Add DOCKERHUB_TOKEN as a secret |
| 4 | — | Use docker/login-action with the secrets |
| Total | 1 line of config | 3 configuration steps |
Cost and limits
| Aspect | GHCR (Free) | GHCR (Pro) | Docker Hub (Free) | Docker Hub (Pro) |
|---|---|---|---|---|
| Storage | 500 MB | 2 GB | Unlimited (public) | Unlimited |
| Private | Included | Included | 1 private repo | Unlimited |
| Pull rate | No practical limit | No limit | 100/6h (anonymous) | 5000/day |
| Price | $0 | $4/month | $0 | $5/month |
Troubleshooting
1. "denied: installation not allowed to Write organization package"
Symptom: The push to GHCR fails with a permissions error.
Cause: permissions: packages: write is missing from the job.
Solution:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # ADD THIS
2. "unauthorized: authentication required" on Docker Hub
Symptom: The login to Docker Hub fails.
Cause: The DOCKERHUB_USERNAME or DOCKERHUB_TOKEN secrets are misconfigured, or the token expired.
Solution:
# Verify that the secrets exist in your repo
# GitHub → Settings → Secrets → Actions
# Regenerate the token in Docker Hub if it expired
# Docker Hub → Account Settings → Security → New Access Token
3. "name unknown: repository name not known" on GHCR
Symptom:
ERROR: name unknown: repository name not known to registry
Cause: The package's name has uppercase letters. GHCR requires lowercase.
Solution:
- name: Repo to lowercase
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ steps.repo.outputs.name }}:${{ github.sha }}
4. A rate limit on Docker Hub during builds
Symptom:
ERROR: toomanyrequests: You have reached your pull rate limit.
Cause: Docker Hub limits anonymous pulls to 100/6h. The FROM python:3.12-slim counts as a pull.
Solution: Authenticate against Docker Hub even for pulls:
- name: Login to Docker Hub (for pulls)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
Or use a base image from GHCR or a mirror.
Exercises
Exercise 1: Push to GHCR with GITHUB_TOKEN
Create a workflow that builds and pushes an image to GHCR. The repo is github.com/your-user/ai-api. It only pushes on a push to main.
See solution
name: Docker Push to GHCR
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Key points:
permissions: packages: writeenables the push to GHCR${{ secrets.GITHUB_TOKEN }}already exists — you don't need to create secretspush: ${{ github.event_name == 'push' }}only pushes on a push to main, not on PRsload: ${{ github.event_name == 'pull_request' }}loads locally on PRs for testing- GHA cache included for fast builds
Exercise 2: Push to Docker Hub with secrets
Create a workflow that pushes to Docker Hub. Assume you already have the DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets configured.
See solution
name: Docker Push to Docker Hub
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/ai-api:${{ github.sha }}
${{ secrets.DOCKERHUB_USERNAME }}/ai-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
The differences from GHCR:
- You don't need
registry:indocker/login-action(Docker Hub is the default) - You don't need
permissions: packages: write(that's only for GHCR) - The tags use
username/repo:taginstead ofghcr.io/owner/repo:tag - You need two manually configured secrets (
DOCKERHUB_USERNAMEandDOCKERHUB_TOKEN)
Exercise 3: Conditional push — GHCR on main, Docker Hub on a release
Create a workflow that: (1) pushes to GHCR on every push to main, (2) pushes to Docker Hub only when you create a release (a v* tag).
See solution
name: Docker Multi-Registry
on:
push:
branches: [main]
tags: ["v*"]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: startsWith(github.ref, 'refs/tags/v')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tags
id: tags
run: |
GHCR_TAG="ghcr.io/${{ github.repository }}:${{ github.sha }}"
echo "ghcr_tag=$GHCR_TAG" >> $GITHUB_OUTPUT
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
DH_TAG="${{ secrets.DOCKERHUB_USERNAME }}/ai-api:${VERSION}"
echo "all_tags=${GHCR_TAG},${DH_TAG}" >> $GITHUB_OUTPUT
else
echo "all_tags=${GHCR_TAG}" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.tags.outputs.all_tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
Key points:
- The trigger includes
tags: ["v*"]to capture releases if: startsWith(github.ref, 'refs/tags/v')makes the Docker Hub login conditional- The tags get computed dynamically depending on whether it's a push to main or a release
- On main: it only pushes to GHCR. On a release: it pushes to both
Exercise 4: Verify the image after the push
Add a step that verifies the image can be pulled from the registry after the push. Use GHCR.
See solution
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify image in registry
run: |
echo "Pulling image from GHCR..."
docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
echo "Verifying image..."
docker run --rm ghcr.io/${{ github.repository }}:${{ github.sha }} python -c "
from src.main import app
print('Image verified from registry')
"
echo "Image digest:"
docker inspect ghcr.io/${{ github.repository }}:${{ github.sha }} \
--format '{{index .RepoDigests 0}}'
Key points:
docker pullverifies that the image is accessible from the registrydocker runverifies that the image works after the pulldocker inspectshows the digest — the image's immutable hash- The runner already has a GHCR login from the previous step
- If the pull fails, you know there's a problem with the push or the permissions
Summary
- ✅ GHCR is the natural default for projects on GitHub — authentication with
GITHUB_TOKEN, zero extra secrets - ✅ Docker Hub requires configuring secrets manually — a username + an Access Token
- ✅
permissions: packages: writeis necessary for pushing to GHCR - ✅ A conditional push — push on main, load on PRs, Docker Hub only on releases
- ✅ You can push to both registries in the same workflow with multi-line tags
- ✅ GHCR requires lowercase in the package's name — convert it if your repo has uppercase letters
- ✅ Docker Hub has rate limits for anonymous pulls — authenticate to avoid getting blocked
- ✅ Verify the push with
docker pull+docker runto confirm the image is available
Additional resources
- GitHub Container Registry docs — Complete GHCR documentation
- Docker Hub docs — Docker Hub's documentation
- docker/login-action — The official action for logging into registries
- GITHUB_TOKEN permissions — The automatic token's permissions
- Docker Hub Access Tokens — How to create tokens in Docker Hub
- GitHub Packages billing — Free storage and bandwidth per plan