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

AspectGHCRDocker Hub
Authentication in ActionsGITHUB_TOKEN (automatic)Manual secrets (username + token)
Extra configOnly permissions: packages: writeCreate a token, add 2 secrets
EcosystemIntegrated with GitHubIndependent
Public imagesUnlimitedUnlimited
Private imagesDepends on the GitHub plan1 free, then paid
Rate limits (pull)Generous for authenticated users100 pulls/6h (anonymous), 200 (authenticated)
Visibility/discoveryLinked to the repoGlobal search on Docker Hub
OCI compliance
The tag's URLghcr.io/owner/repo:tagowner/repo:tag
Ideal forProjects on GitHub, teams on GitHubPublic 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

StepGHCRDocker Hub
1Add permissions: packages: write to the jobCreate an Access Token in Docker Hub
2Use docker/login-action with GITHUB_TOKENAdd DOCKERHUB_USERNAME as a secret
3✅ DoneAdd DOCKERHUB_TOKEN as a secret
4Use docker/login-action with the secrets
Total1 line of config3 configuration steps

Cost and limits

AspectGHCR (Free)GHCR (Pro)Docker Hub (Free)Docker Hub (Pro)
Storage500 MB2 GBUnlimited (public)Unlimited
PrivateIncludedIncluded1 private repoUnlimited
Pull rateNo practical limitNo limit100/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: write enables the push to GHCR
  • ${{ secrets.GITHUB_TOKEN }} already exists — you don't need to create secrets
  • push: ${{ github.event_name == 'push' }} only pushes on a push to main, not on PRs
  • load: ${{ 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: in docker/login-action (Docker Hub is the default)
  • You don't need permissions: packages: write (that's only for GHCR)
  • The tags use username/repo:tag instead of ghcr.io/owner/repo:tag
  • You need two manually configured secrets (DOCKERHUB_USERNAME and DOCKERHUB_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 pull verifies that the image is accessible from the registry
  • docker run verifies that the image works after the pull
  • docker inspect shows 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: write is 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 run to confirm the image is available

Additional resources

  1. GitHub Container Registry docs — Complete GHCR documentation
  2. Docker Hub docs — Docker Hub's documentation
  3. docker/login-action — The official action for logging into registries
  4. GITHUB_TOKEN permissions — The automatic token's permissions
  5. Docker Hub Access Tokens — How to create tokens in Docker Hub
  6. GitHub Packages billing — Free storage and bandwidth per plan