Terminal File
Mon, May 04, 10:33 PM
mateux@tars :~$ ~
← Back to posts

Migrating to GitHub Container Registry

Published at Oct 7, 2025 · 6 min read

githubdockercontainer-registryci-cd

I used to avoid GitHub Container Registry (GHCR), assuming it was expensive or complex compared to Docker Hub. I was wrong.

After finally migrating my personal projects, I realized GHCR offers tighter integration with GitHub Actions, better permissions, and generous free-tier limits.

What is GitHub Container Registry (GHCR)?

GitHub Container Registry is GitHub’s native container registry service, launched as part of GitHub Packages in 2021 (Introducing GitHub Container Registry). It’s designed to work seamlessly with GitHub repositories and provides enhanced security features, better integration with GitHub Actions, and more granular access controls.

Key features:

  • Native GitHub integration - packages are tied to repositories
  • Fine-grained permissions - control access at the package level
  • Anonymous public access - no authentication needed for public packages
  • GitHub Actions integration - built-in support with GITHUB_TOKEN
  • Multiple package types - not just containers (npm, Maven, NuGet, etc.)

What is Docker Container Registry (Docker Hub)?

Docker Hub is Docker’s official cloud-based registry service. It’s the default registry when you run docker pull or docker push without specifying a registry URL.

Key features:

  • Industry standard - most widely used container registry
  • Mature ecosystem - extensive tooling and community
  • Docker Official Images - curated base images
  • Automated builds - trigger builds from Git repositories
  • Teams and organizations - well established collaboration features

Why migrate to GHCR?

There’s no one-size-fits-all answer here. Both registries have their strengths and weaknesses. However, I find GitHub Container Registry better suited for my needs, especially for public repositories on my personal GitHub profile. Here’s a comparison of free tiers:

Comparison table

FeatureDocker HubGitHub Container Registry
Unlimited public repositories✅ (Free)✅ (Free)
Unlimited private repositories❌ (1 free private repo, then paid)✅ (500MB free, then pay-as-you-go)
Unlimited pulls for public packages✅ (authenticated), 200 pulls/6h per IP (anonymous)✅, completely free (storage + bandwidth)
Simple authentication❌ Requires Docker credentials✅ Uses GitHub tokens (PAT or GITHUB_TOKEN)
GitHub integration❌ External service✅ Native integration
Permissions❌ Repository-level✅ Fine-grained, package-level
Package visibility✅ Public/Private✅ Public/Private/Internal
CI/CD integration✅ (Authentication required)✅ (Built-in with GITHUB_TOKEN)

Understanding GHCR billing

It’s crucial to understand how GitHub Container Registry billing works, especially for private packages:

Public packages: Completely free — no storage or bandwidth costs Private packages:

  • Free tier includes 500MB storage and 1GB data transfer per month
  • Usage is calculated hourly for storage (GB-hours per month)
  • Data transfer resets monthly, storage does not
  • GitHub Actions downloads using GITHUB_TOKEN don’t count against transfer limits

You can check the up-to-date pricing details on the GitHub Packages billing page

Migrating this blog to GHCR

Here’s how I migrated my blog’s container workflow:

Step 1: Update the GitHub Actions workflow

My original workflow in .github/workflows/build.yml was pushing to Docker Hub. Here’s how I updated it to use GHCR:

Before (Docker Hub):

name: Build

on:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 1
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
      - 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@v5
        with:
          context: .
          platforms: linux/amd64
          push: true
          tags: mateuxlucax/mateux-dot-dev:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
name: Build

on:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 1
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
      - 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@v5
        with:
          context: .
          platforms: linux/amd64
          push: true
          tags: mateuxlucax/mateux-dot-dev:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

You can see that I had DOCKERHUB_USERNAME and DOCKERHUB_TOKEN stored in GitHub Secrets for authentication. This was always a hassle to manage.

After (GHCR):

name: Build and Push Docker Image

on:
  push:
    branches: [ "main" ]
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
name: Build and Push Docker Image

on:
  push:
    branches: [ "main" ]
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Step 2: Remove secrets

This I cannot show you in code, but I removed the DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets from my GitHub repository settings. GHCR uses the built-in GITHUB_TOKEN, so no additional secrets are needed.

Step 3: Update deployment configurations

I also needed to update my docker-compose.yml to pull from the new registry:

Before:

services:
  mateux-dot-dev:
    image: mateuxlucax/mateux-dot-dev:latest
    # ... rest of config
services:
  mateux-dot-dev:
    image: mateuxlucax/mateux-dot-dev:latest
    # ... rest of config

After:

services:
  mateux-dot-dev:
    image: ghcr.io/mateuxlucax/mateux-dot-dev:latest
    # ... rest of config
services:
  mateux-dot-dev:
    image: ghcr.io/mateuxlucax/mateux-dot-dev:latest
    # ... rest of config

Step 4: Clean up Docker Hub (optional)

After verifying the migration worked, I:

  • Removed the old Docker Hub repository
  • Deleted the Docker Hub credentials from GitHub Secrets
  • Updated any documentation or deployment scripts

Key differences in the migration

Authentication changes

  • Docker Hub: Required storing DOCKERHUB_USERNAME and DOCKERHUB_TOKEN in GitHub Secrets
  • GHCR: Uses built-in GITHUB_TOKEN - no additional secrets needed

Registry URL format

  • Docker Hub: username/repository:tag
  • GHCR: ghcr.io/username/repository:tag

Permissions

  • Docker Hub: Repository-level access control
  • GHCR: Package-level permissions that can be different from repository permissions

Things to consider before migrating

Potential drawbacks of GHCR:

  • Less mature: Docker Hub has been around longer
  • GitHub dependency: If GitHub goes down, your registry is also down
  • Learning curve: Different permission model and UI
  • Ecosystem: Some tools are still primarily designed for Docker Hub

Summary

Migrating from Docker Hub to GitHub Container Registry was straightforward and brought immediate benefits: no separate credentials, seamless GitHub Actions integration, and no rate limiting for public packages. For my use case (a personal blog with a simple CI/CD pipeline), GHCR provides everything I need.

Useful resources

The complete updated workflow for this blog is available in my GitHub repository. Feel free to use it as a reference for your own migration!