Docker Hub vs ECR vs ACR vs GHCR — Private Registries Explained
You have built a Docker image. Now what? You need somewhere to store it so your CI/CD pipeline, your Kubernetes cluster, or your teammates can pull it. That somewhere is a container registry. But which one? The answer depends on your cloud provider, team size, budget, and how much you enjoy YAML.
What Is a Container Registry?
A container registry is a server that stores and distributes Docker images. Think of it like npm for Node packages or PyPI for Python — but for container images. Every registry supports the OCI (Open Container Initiative) distribution spec, which means docker push and docker pull work the same way regardless of which registry you use.
The key difference between registries is where they run, how they authenticate, and what they charge you.
Docker Hub: The Default
Docker Hub is where Docker pulls from by default. It is the largest public registry with millions of images.
# Pull from Docker Hub (implicit registry)
docker pull nginx:alpine
# This is actually: docker pull docker.io/library/nginx:alpine
# Login to Docker Hub
docker login
# Tag and push to your Docker Hub account
docker tag myapp:latest yourusername/myapp:1.0.0
docker push yourusername/myapp:1.0.0
Docker Hub's free tier has limitations that matter at scale:
- Free: 1 private repo, unlimited public repos, 200 pull requests per 6 hours (anonymous), 200 per 6 hours (authenticated free)
- Pro ($5/mo): Unlimited private repos, 5,000 pulls per day, vulnerability scanning
- Team ($9/user/mo): Team management, audit logs, role-based access
The rate limiting hit many CI/CD pipelines in 2020 when Docker introduced pull limits. If your builds suddenly fail with "Too Many Requests," this is why.
AWS ECR: Native for AWS Workloads
Amazon Elastic Container Registry integrates tightly with ECS, EKS, Lambda, and IAM.
# Authenticate Docker to ECR (token valid for 12 hours)
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin \
123456789012.dkr.ecr.us-east-1.amazonaws.com
# Create a repository (required before pushing)
aws ecr create-repository --repository-name myapp \
--image-scanning-configuration scanOnPush=true
# Tag and push
docker tag myapp:latest 123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:1.0.0
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:1.0.0
# List images in a repository
aws ecr list-images --repository-name myapp
ECR pricing: $0.10/GB/month for storage, $0.09/GB for data transfer out. No per-pull charges. For AWS-native workloads (ECS, EKS), pulls within the same region are free.
ECR also offers lifecycle policies to automatically clean up old images:
# Delete untagged images older than 14 days
aws ecr put-lifecycle-policy --repository-name myapp \
--lifecycle-policy-text '{
"rules": [{
"rulePriority": 1,
"description": "Expire untagged images older than 14 days",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 14
},
"action": { "type": "expire" }
}]
}'
Azure ACR: Native for Azure Workloads
Azure Container Registry works seamlessly with AKS, App Service, and Azure Functions.
# Create an ACR instance
az acr create --name myregistry --resource-group mygroup --sku Basic
# Login
az acr login --name myregistry
# Tag and push
docker tag myapp:latest myregistry.azurecr.io/myapp:1.0.0
docker push myregistry.azurecr.io/myapp:1.0.0
# List repositories
az acr repository list --name myregistry --output table
# Show image tags
az acr repository show-tags --name myregistry --repository myapp
ACR tiers: Basic ($0.167/day), Standard ($0.667/day), Premium ($1.667/day). Premium adds geo-replication, content trust, and private endpoints.
GitHub Container Registry (GHCR)
GHCR integrates directly with GitHub repositories and Actions. If your code is on GitHub, this is the path of least resistance.
# Login with a personal access token (needs write:packages scope)
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
# Tag and push
docker tag myapp:latest ghcr.io/yourusername/myapp:1.0.0
docker push ghcr.io/yourusername/myapp:1.0.0
In GitHub Actions, authentication is automatic:
# .github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
GHCR pricing: Free for public images. Private images use your GitHub Actions storage quota (free tier includes 500 MB).
Self-Hosted Registry
You can run your own registry using Docker's official registry image. This gives you full control and zero external dependencies.
# Start a local registry
docker run -d -p 5000:5000 --name registry \
-v registry-data:/var/lib/registry \
--restart always \
registry:2
# Tag and push to local registry
docker tag myapp:latest localhost:5000/myapp:1.0.0
docker push localhost:5000/myapp:1.0.0
# Pull from local registry
docker pull localhost:5000/myapp:1.0.0
# List repositories (API call)
curl http://localhost:5000/v2/_catalog
For production self-hosted registries, add TLS and authentication:
# docker-compose.yml for production registry
services:
registry:
image: registry:2
ports:
- "443:5000"
environment:
REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt
REGISTRY_HTTP_TLS_KEY: /certs/domain.key
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
- registry-data:/var/lib/registry
- ./certs:/certs:ro
- ./auth:/auth:ro
volumes:
registry-data:
# Generate htpasswd file for basic auth
docker run --rm --entrypoint htpasswd httpd:2 -Bbn admin secretpass > auth/htpasswd
Image Tagging Strategies
How you tag images directly affects your deployment reliability and debugging ability.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| Semantic version | myapp:1.2.3 | Clear release identity | Manual version bumps |
| Git SHA | myapp:a1b2c3d | Exact code traceability | Hard to read |
| Both | myapp:1.2.3 + myapp:a1b2c3d | Best of both worlds | Two tags per build |
| Branch + SHA | myapp:main-a1b2c3d | Identifies branch + commit | Verbose |
latest | myapp:latest | Convenient | Unreliable, unclear version |
The recommended approach is to tag with both a semantic version and the git SHA:
VERSION="1.2.3"
GIT_SHA=$(git rev-parse --short HEAD)
docker build -t myapp:${VERSION} -t myapp:${GIT_SHA} -t myapp:latest .
docker push myapp:${VERSION}
docker push myapp:${GIT_SHA}
docker push myapp:latest
Never deploy latest to production. It tells you nothing about what version is running, and pulling latest on different nodes at different times can give you different images.
Registry Comparison Table
| Feature | Docker Hub | AWS ECR | Azure ACR | GHCR | Self-Hosted |
|---|---|---|---|---|---|
| Free tier | 1 private repo | None | None | 500 MB (private) | Free (you host) |
| Public images | Unlimited free | Not designed for public | Not designed for public | Unlimited free | Your bandwidth |
| Auth integration | Docker ID | IAM | Azure AD | GitHub tokens | htpasswd / LDAP |
| Vulnerability scanning | Pro plan | Built-in (free) | Standard+ tier | Dependabot | Manual tooling |
| Geo-replication | No | Cross-region copies | Premium tier | CDN-backed | Manual setup |
| Best for | Open source, small teams | AWS-native workloads | Azure-native workloads | GitHub-centric teams | Air-gapped / on-prem |
| CI/CD integration | Any | AWS CodeBuild, Actions | Azure DevOps, Actions | GitHub Actions (native) | Any |
Wrapping Up
Your choice of registry should follow your infrastructure. On AWS? Use ECR. On Azure? Use ACR. Code on GitHub and not cloud-locked? GHCR keeps everything in one place. Need air-gapped or on-premises? Self-host. And Docker Hub remains the default for open-source images and getting started.
In the next post, we will dive into Docker Debugging — how to figure out why your container will not start, how to get inside running containers, and the essential commands that turn frustrating container problems into quick fixes.
