Docker Hub vs. GHCR: Which is the Best Choice?
If you’ve ever run into a Too Many Requests error while pulling an image from Docker Hub, you know how frustrating it can be. Docker Hub’s free tier limits you to only 200 pulls every 6 hours. This cap is easily reached if your CI/CD system runs continuously. Not to mention, you only get a single free private repository.
That’s why I’ve completely switched to GitHub Container Registry (GHCR). With a personal account, you get 500MB of storage and 1GB of monthly bandwidth entirely for free. Everything from your source code to your images lives under one roof on GitHub, making management much more convenient.
Quick Start: Push a Docker Image to GHCR in 5 Minutes
You don’t need to install any new tools. Just make sure you have Docker installed in your terminal.
Step 1: Create a Personal Access Token (PAT)
GitHub uses tokens instead of traditional passwords for security. Go to Settings > Developer settings > Personal access tokens > Tokens (classic) and select:
write:packages: To upload images.read:packages: To pull images to your server.delete:packages: Used when you need to clean up old images.
Step 2: Log in via CLI
Store your token in an environment variable and log in using the following command:
export CR_PAT=YOUR_TOKEN
echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
Step 3: Tag and Push the Image
Assuming your local image is named my-app. Tag it according to GHCR standards and push it:
# Tag format: ghcr.io/username/image-name:version
docker tag my-app ghcr.io/username/my-app:v1.0
# Push to registry
docker push ghcr.io/username/my-app:v1.0
Check the Packages tab on your GitHub profile, and you’ll see your image safely stored there.
What Makes GHCR Special?
Unlike Docker Hub, GHCR is tightly integrated with Organizations and Users. This is incredibly useful for permission management. You can keep an image Private but still grant access to a specific repository. You no longer need to configure SSH keys or perform tedious manual logins on your server.
Automating with GitHub Actions
This is the most valuable part. Instead of building manually, I always use GitHub Actions to automate the workflow: every git push automatically publishes a new image.
Below is a sample workflow file (.github/workflows/publish.yml) that I often use for Node.js projects:
name: Build and Push Docker Image
on:
push:
branches: ["main"]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in 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@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
The great thing here is ${{ secrets.GITHUB_TOKEN }}. This is an auto-generated token that only exists during the Action’s runtime. It’s much more secure than using a static PAT.
Tips for Repository Access Permissions
How can a server or another repo pull a Private image? The answer lies in the Manage Actions access feature.
- Go to Packages on your personal GitHub.
- Select the corresponding image > Package settings.
- Find the Manage Actions access section at the bottom.
- Select Add repository and search for the repo you want to authorize.
- Set the permission to Read so the server can pull the image freely.
This method allows you to strictly control who is allowed to use the image without sharing your main account credentials.
Real-world Experience for Cost Optimization
After using GHCR for a long time for projects at itfromzero, I have a few small tips for you:
- Regular Cleanup: GHCR provides the first 500MB for free. If your images are heavy (around 200MB/image), just 3 builds will nearly exhaust your storage. Use Actions to automatically delete old images after 30 days.
- Use Multi-stage Builds: Optimize your Dockerfile. A Node.js image using Alpine is only about 100MB, while the full version can reach 1GB. Lightweight images make push/pull faster and save significant storage.
- Token Security: Never hardcode your PAT into a YAML file. Always prioritize using the built-in
GITHUB_TOKEN. - Test Locally First: Don’t waste CI/CD resources fixing silly syntax errors. Test your image with Docker Compose on your local machine before pushing.
Switching to GHCR has made my deployment process to VPS much smoother. Just log in once, and everything afterwards runs automatically via scripts. I hope this article helps you master GHCR more easily. If you encounter any errors during YAML configuration, feel free to leave a comment below!

