Container Registry Management Strategies
A comprehensive guide to container registry management covering Docker Hub, GHCR, ACR, ECR, image tagging strategies, vulnerability scanning, retention policies, and CI/CD integration.
Container Registry Management Strategies
Overview
A container registry is the artifact store that sits between your CI pipeline and your production infrastructure. It holds your built images, manages access control, scans for vulnerabilities, and serves layers to every node pulling your containers. Getting registry management wrong means stale images in production, ballooning storage costs, security vulnerabilities slipping through, and deployments that break because someone pushed latest on a Friday afternoon. This guide covers the major registry providers, tagging strategies that actually work at scale, automated scanning, retention policies, and a complete CI/CD pipeline for a Node.js application that ties it all together.
Prerequisites
- Docker Desktop or Docker Engine installed (v24+)
- A working Node.js application with a Dockerfile
- Basic understanding of Docker image layers and builds
- A CI/CD platform account (GitHub Actions, GitLab CI, or similar)
- CLI tools:
docker,awsCLI,azCLI, ordoctldepending on your registry provider - Familiarity with YAML-based CI/CD configuration
Registry Options Comparison
Not all registries are created equal. Your choice depends on where your infrastructure lives, what your team already uses, and how much you want to pay. Here is a direct comparison based on what matters in production.
Docker Hub
Docker Hub is the original public registry and still the default when you run docker pull nginx. The free tier gives you one private repository with unlimited public repositories. The Pro tier ($5/month) bumps that to unlimited private repos with 5,000 pulls per day. The rate limiting on the free tier (100 pulls per 6 hours for anonymous, 200 for authenticated) is the thing that will bite you first. CI pipelines that pull base images from Docker Hub without authentication will start failing intermittently once you scale past a handful of build agents.
# Authenticate to Docker Hub
docker login -u shanegrizzly --password-stdin < ~/dockerhub-token.txt
# Push an image
docker tag myapp:latest shanegrizzly/myapp:1.0.0
docker push shanegrizzly/myapp:1.0.0
GitHub Container Registry (GHCR)
GHCR is tightly integrated with GitHub repositories and GitHub Actions. Images are scoped to your GitHub user or organization, and permissions tie into GitHub's existing role model. The free tier includes 500MB of storage and 1GB of data transfer for private packages. Public packages are free and unlimited. If you are already on GitHub for source control and CI, GHCR is the path of least resistance.
# Authenticate to GHCR
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
# Tag and push
docker tag myapp:latest ghcr.io/shanegrizzly/myapp:1.0.0
docker push ghcr.io/shanegrizzly/myapp:1.0.0
One advantage: GHCR supports linking packages to repositories, so you get visibility into which repo produced which image directly in the GitHub UI.
AWS Elastic Container Registry (ECR)
ECR is the right choice when your workloads run on ECS, EKS, or Lambda. It integrates with IAM for fine-grained access control, supports image scanning with Amazon Inspector, and replicates across regions. Pricing is $0.10/GB/month for storage and $0.09/GB for data transfer out. ECR lifecycle policies are the best built-in retention mechanism of any registry -- you define rules in JSON and ECR automatically cleans up old images.
# Authenticate 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 repository
aws ecr create-repository --repository-name myapp --image-scanning-configuration scanOnPush=true
# 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
Azure Container Registry (ACR)
ACR integrates with AKS, Azure DevOps, and Azure AD. The Basic tier ($0.167/day) gives you 10GB storage. Standard ($0.667/day) gives you 100GB with webhooks. Premium ($1.667/day) adds geo-replication, content trust, and private endpoints. ACR Tasks let you build images in Azure without a local Docker daemon, which is useful for CI/CD when you do not want to manage build agents.
# Authenticate to ACR
az acr login --name myregistry
# Push
docker tag myapp:latest myregistry.azurecr.io/myapp:1.0.0
docker push myregistry.azurecr.io/myapp:1.0.0
DigitalOcean Container Registry
DigitalOcean Container Registry is simple and affordable. The Starter tier is free with 500MB storage. The Basic tier ($5/month) gives you 5GB. Professional ($20/month) gives you unlimited repositories and 100GB. It integrates directly with DigitalOcean Kubernetes (DOKS) and App Platform. If your infrastructure is on DigitalOcean, this is the straightforward choice.
# Authenticate
doctl registry login
# Push
docker tag myapp:latest registry.digitalocean.com/my-registry/myapp:1.0.0
docker push registry.digitalocean.com/my-registry/myapp:1.0.0
Quick Comparison Table
Provider Free Tier Private Repos Scanning Geo-Replication
─────────────────────────────────────────────────────────────────────────────────
Docker Hub 1 private repo Pro: unlimited Paid plans No
GHCR 500MB storage Yes Via Actions No
AWS ECR 500MB/12 months Yes Built-in Yes (cross-region)
Azure ACR None (Basic ~$5) Yes Built-in Premium tier
DO Registry 500MB Starter: 1 No built-in No
Authentication and Access Control
Every registry has its own authentication model, but they all boil down to the same pattern: generate a credential, feed it to docker login, and scope permissions as tightly as possible.
Service Account Patterns
Never use your personal credentials in CI/CD. Create dedicated service accounts or tokens with minimal permissions.
For ECR, create an IAM policy that allows only the actions your pipeline needs:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:BatchGetImage"
],
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/myapp"
},
{
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
}
]
}
For GHCR in GitHub Actions, use the built-in GITHUB_TOKEN -- it already has packages:write scope when configured:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Rotating Credentials with Node.js
If your application needs to pull registry credentials programmatically (for example, to generate Kubernetes pull secrets), here is how to handle ECR token rotation:
var AWS = require("aws-sdk");
var ecr = new AWS.ECR({ region: "us-east-1" });
function getEcrToken(callback) {
ecr.getAuthorizationToken({}, function(err, data) {
if (err) {
return callback(err);
}
var authData = data.authorizationData[0];
var token = Buffer.from(authData.authorizationToken, "base64").toString("utf8");
var parts = token.split(":");
var result = {
username: parts[0],
password: parts[1],
endpoint: authData.proxyEndpoint,
expiresAt: authData.expiresAt
};
callback(null, result);
});
}
// Usage
getEcrToken(function(err, creds) {
if (err) {
console.error("Failed to get ECR token:", err.message);
process.exit(1);
}
console.log("Token expires at:", creds.expiresAt);
console.log("Endpoint:", creds.endpoint);
// Feed creds.username and creds.password to your Kubernetes secret
});
Image Tagging Strategies
Tagging is the most opinionated topic in container management, and for good reason. A bad tagging strategy makes rollbacks impossible and audits meaningless. Here is what works.
The Three-Tag Strategy
Every image gets three tags: a semantic version, a git SHA, and a mutable environment tag.
# Build the image
docker build -t myapp:build .
# Tag with semver
docker tag myapp:build ghcr.io/myorg/myapp:1.4.2
# Tag with git SHA (first 7 characters)
docker tag myapp:build ghcr.io/myorg/myapp:sha-a1b2c3d
# Tag with environment
docker tag myapp:build ghcr.io/myorg/myapp:production
# Push all three
docker push ghcr.io/myorg/myapp:1.4.2
docker push ghcr.io/myorg/myapp:sha-a1b2c3d
docker push ghcr.io/myorg/myapp:production
Semver tags (1.4.2) are immutable. Once you push 1.4.2, that tag should never point to a different digest. This is your release tag for changelogs and customer communication.
Git SHA tags (sha-a1b2c3d) create a direct link from a running container back to the exact commit that produced it. When something breaks in production, docker inspect gives you the image tag, and the SHA takes you straight to the code.
Environment tags (production, staging) are mutable pointers that always reference the currently deployed version for that environment. These exist for convenience -- dashboards, monitoring queries, quick local testing -- not for deployment pinning.
Never Trust latest
The latest tag is the default when no tag is specified. It is mutable, ambiguous, and the root cause of more deployment incidents than I can count. If two developers push latest thirty seconds apart, your deployment pulls whichever one wins the race. Pin to a specific version or digest:
# Bad - what version is this?
docker pull myapp:latest
# Good - exact version
docker pull myapp:1.4.2
# Best - immutable digest
docker pull myapp@sha256:3e8a1c7f9d2b4e5a6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b
Tagging Automation Script
Here is a Node.js script that generates tags based on your git state and package.json version:
var execSync = require("child_process").execSync;
var path = require("path");
function generateTags(registry, repository) {
var pkg = require(path.join(process.cwd(), "package.json"));
var version = pkg.version;
var gitSha = execSync("git rev-parse --short=7 HEAD").toString().trim();
var branch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim();
var isDirty = execSync("git status --porcelain").toString().trim().length > 0;
if (isDirty) {
console.warn("WARNING: Working directory has uncommitted changes");
}
var prefix = registry + "/" + repository;
var tags = [
prefix + ":" + version,
prefix + ":sha-" + gitSha
];
if (branch === "main" || branch === "master") {
tags.push(prefix + ":latest");
tags.push(prefix + ":production");
} else if (branch === "develop") {
tags.push(prefix + ":staging");
}
return tags;
}
// Usage
var tags = generateTags("ghcr.io/myorg", "myapp");
tags.forEach(function(tag) {
console.log(tag);
});
Output:
ghcr.io/myorg/myapp:1.4.2
ghcr.io/myorg/myapp:sha-a1b2c3d
ghcr.io/myorg/myapp:latest
ghcr.io/myorg/myapp:production
Automated Image Builds in CI/CD
GitHub Actions Workflow
This workflow builds on every push to main, tags appropriately, and pushes to GHCR:
name: Build and Push
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
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=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
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
The docker/metadata-action is the best thing Docker ever released for CI. It automatically generates tags based on git refs, semver tags, branch names, and SHAs. The cache-from and cache-to with type=gha uses GitHub Actions cache backend, which is significantly faster than pushing cache layers to the registry.
Image Scanning and Vulnerability Management
Pushing unscanned images to production is negligent. Every major registry offers some form of vulnerability scanning. Here is how to integrate scanning into your pipeline so vulnerable images never reach deployment.
Trivy in CI/CD
Trivy is the open-source scanner I recommend. It is fast, accurate, covers OS packages and application dependencies, and integrates with every CI platform:
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
Setting exit-code: 1 means the pipeline fails if any CRITICAL or HIGH vulnerabilities are found. The ignore-unfixed flag skips vulnerabilities that have no available patch -- you cannot fix what upstream has not fixed.
Scanning with Node.js
Here is a script that wraps Trivy and parses its JSON output for integration with alerting systems:
var execSync = require("child_process").execSync;
var fs = require("fs");
function scanImage(imageRef) {
var outputFile = "/tmp/trivy-results.json";
try {
execSync(
"trivy image --format json --output " + outputFile +
" --severity CRITICAL,HIGH " + imageRef,
{ stdio: "pipe" }
);
} catch (err) {
// Trivy exits non-zero when vulnerabilities are found
}
var results = JSON.parse(fs.readFileSync(outputFile, "utf8"));
var summary = { critical: 0, high: 0, targets: [] };
if (results.Results) {
results.Results.forEach(function(target) {
var vulns = target.Vulnerabilities || [];
var criticals = vulns.filter(function(v) { return v.Severity === "CRITICAL"; });
var highs = vulns.filter(function(v) { return v.Severity === "HIGH"; });
summary.critical += criticals.length;
summary.high += highs.length;
if (criticals.length > 0 || highs.length > 0) {
summary.targets.push({
target: target.Target,
type: target.Type,
criticals: criticals.length,
highs: highs.length,
details: vulns.slice(0, 5).map(function(v) {
return v.VulnerabilityID + " (" + v.Severity + "): " + v.PkgName + " " + v.InstalledVersion;
})
});
}
});
}
return summary;
}
// Usage
var result = scanImage("ghcr.io/myorg/myapp:1.4.2");
console.log("Critical:", result.critical, "High:", result.high);
if (result.critical > 0) {
console.error("BLOCKING: Critical vulnerabilities found");
result.targets.forEach(function(t) {
console.error(" " + t.target + " (" + t.type + "):");
t.details.forEach(function(d) { console.error(" - " + d); });
});
process.exit(1);
}
Output when vulnerabilities are found:
Critical: 2 High: 7
BLOCKING: Critical vulnerabilities found
node_modules/package-lock.json (npm):
- CVE-2024-38816 (CRITICAL): express 4.18.2
- CVE-2024-39338 (CRITICAL): axios 1.6.0
usr/lib/x86_64-linux-gnu (debian):
- CVE-2024-2961 (HIGH): glibc 2.36-9+deb12u4
ECR Scan-on-Push
If you are on ECR, enable scan-on-push at repository creation. The results are available through the AWS API:
# Enable scanning
aws ecr put-image-scanning-configuration \
--repository-name myapp \
--image-scanning-configuration scanOnPush=true
# Check scan results
aws ecr describe-image-scan-findings \
--repository-name myapp \
--image-id imageTag=1.4.2
# Output shows finding severity counts
# CRITICAL: 0, HIGH: 2, MEDIUM: 5, LOW: 12, INFORMATIONAL: 3
Retention Policies and Garbage Collection
Registries grow without bound unless you actively prune them. I have seen teams paying hundreds of dollars per month storing images from branches that were merged two years ago. Set up retention policies on day one.
ECR Lifecycle Policies
ECR lifecycle policies are the gold standard. Define rules in JSON and ECR handles the rest:
{
"rules": [
{
"rulePriority": 1,
"description": "Keep last 10 production images",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["production", "v"],
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": {
"type": "expire"
}
},
{
"rulePriority": 2,
"description": "Remove untagged images after 1 day",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 1
},
"action": {
"type": "expire"
}
},
{
"rulePriority": 3,
"description": "Remove dev images after 14 days",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["sha-", "dev-", "pr-"],
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 14
},
"action": {
"type": "expire"
}
}
]
}
# Apply the policy
aws ecr put-lifecycle-policy \
--repository-name myapp \
--lifecycle-policy-text file://lifecycle-policy.json
GHCR Cleanup with the API
GHCR does not have built-in lifecycle policies, so you script it. Here is a GitHub Actions workflow that cleans up old untagged images:
name: Cleanup GHCR
on:
schedule:
- cron: '0 3 * * 0' # Weekly on Sunday at 3 AM
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Delete untagged images
uses: actions/delete-package-versions@v5
with:
package-name: myapp
package-type: container
min-versions-to-keep: 20
delete-only-untagged-versions: true
Custom Retention Script
For registries without built-in policies, this Node.js script talks to the Docker Registry HTTP API to enumerate and delete old tags:
var https = require("https");
var url = require("url");
var REGISTRY_URL = "https://registry.example.com";
var REPOSITORY = "myapp";
var KEEP_DAYS = 30;
var KEEP_TAGS = ["latest", "production", "staging"];
function registryRequest(method, path, callback) {
var parsed = url.parse(REGISTRY_URL + path);
var options = {
hostname: parsed.hostname,
path: parsed.path,
method: method,
headers: {
"Accept": "application/vnd.docker.distribution.manifest.v2+json"
}
};
var req = https.request(options, function(res) {
var body = "";
res.on("data", function(chunk) { body += chunk; });
res.on("end", function() {
callback(null, { statusCode: res.statusCode, headers: res.headers, body: body });
});
});
req.on("error", callback);
req.end();
}
function listTags(callback) {
registryRequest("GET", "/v2/" + REPOSITORY + "/tags/list", function(err, res) {
if (err) return callback(err);
var data = JSON.parse(res.body);
callback(null, data.tags || []);
});
}
function shouldDelete(tag) {
if (KEEP_TAGS.indexOf(tag) !== -1) return false;
if (/^v?\d+\.\d+\.\d+$/.test(tag)) return false; // Keep semver tags
return true;
}
listTags(function(err, tags) {
if (err) {
console.error("Failed to list tags:", err.message);
process.exit(1);
}
var toDelete = tags.filter(shouldDelete);
console.log("Total tags:", tags.length);
console.log("Tags to delete:", toDelete.length);
console.log("Tags to keep:", tags.length - toDelete.length);
toDelete.forEach(function(tag) {
console.log(" Deleting:", tag);
});
});
Multi-Architecture Images with Buildx
If you deploy to both x86 and ARM (Graviton instances on AWS, M-series Macs for local development), you need multi-arch images. Docker Buildx builds for multiple platforms in a single command and creates a manifest list that lets Docker pull the right architecture automatically.
# Create a buildx builder
docker buildx create --name multiarch --driver docker-container --use
# Build for multiple platforms and push
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag ghcr.io/myorg/myapp:1.4.2 \
--push \
.
# Inspect the manifest
docker buildx imagetools inspect ghcr.io/myorg/myapp:1.4.2
Output:
Name: ghcr.io/myorg/myapp:1.4.2
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:abc123...
Manifests:
Name: ghcr.io/myorg/myapp:1.4.2@sha256:def456...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Name: ghcr.io/myorg/myapp:1.4.2@sha256:ghi789...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
In GitHub Actions:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build multi-arch
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/myorg/myapp:1.4.2
The ARM64 build will be slower under QEMU emulation (3-5x slower than native). For production pipelines, use native ARM runners or cross-compilation in your Dockerfile to avoid the emulation penalty.
Registry Mirroring and Caching Proxies
When you have dozens of build agents or Kubernetes nodes all pulling the same base images from Docker Hub, you will hit rate limits. A pull-through cache solves this by proxying and caching upstream images locally.
Docker Registry as Pull-Through Cache
# docker-compose.yml for a pull-through cache
version: "3.8"
services:
registry-cache:
image: registry:2
ports:
- "5000:5000"
environment:
REGISTRY_PROXY_REMOTEURL: "https://registry-1.docker.io"
REGISTRY_STORAGE_DELETE_ENABLED: "true"
REGISTRY_STORAGE_CACHE_BLOBDESCRIPTOR: "inmemory"
volumes:
- registry-cache-data:/var/lib/registry
volumes:
registry-cache-data:
Configure Docker on each node to use the cache as a mirror:
{
"registry-mirrors": ["http://registry-cache.internal:5000"]
}
Now docker pull node:20-alpine goes to your cache first. The first pull still hits Docker Hub; subsequent pulls on any machine serve from cache. In a cluster with 50 nodes, this reduces Docker Hub pulls by 95%+ and eliminates rate limit failures entirely.
Cost Optimization Across Providers
Registry costs sneak up on you. Here is how to keep them under control.
Storage Costs
Provider Storage Cost Data Transfer Out
──────────────────────────────────────────────────────────
Docker Hub Pro Included ($5/mo) Included
GHCR $0.25/GB/month $0.50/GB
AWS ECR $0.10/GB/month $0.09/GB (cross-region)
Azure ACR Basic Included (10GB) Included (in-region)
DO Registry Included (per tier) Included
Cost Reduction Strategies
Multi-stage builds -- Reduce image size from 1GB to 150MB. Smaller images mean less storage and faster transfers.
Shared base layers -- If all your services use
node:20-alpine, that base layer is stored once and deduplicated across images. Standardize on a single base image.Aggressive retention policies -- Delete dev/PR images after 14 days. Keep only the last 10-20 production releases.
In-region pulls -- Most providers do not charge for data transfer within the same region. Keep your registry and compute in the same region.
# Check your ECR storage usage
aws ecr describe-repositories --query 'repositories[].{Name:repositoryName}' --output table
# Get image sizes for a repository
aws ecr describe-images --repository-name myapp \
--query 'imageDetails[].{Tag:imageTags[0],SizeMB:imageSizeInBytes}' \
--output table
Private Registry Setup with Harbor
Harbor is the open-source registry for teams that want full control. It runs on your own infrastructure, supports vulnerability scanning with Trivy, RBAC, image signing, replication to other registries, and audit logging. If compliance requires that your images never leave your network, Harbor is the answer.
Deploying Harbor with Docker Compose
# Download Harbor installer
curl -sL https://github.com/goharbor/harbor/releases/download/v2.11.0/harbor-offline-installer-v2.11.0.tgz | tar xz
cd harbor
# Copy and edit configuration
cp harbor.yml.tmpl harbor.yml
Key configuration in harbor.yml:
hostname: registry.internal.company.com
http:
port: 80
https:
port: 443
certificate: /etc/ssl/certs/registry.crt
private_key: /etc/ssl/private/registry.key
harbor_admin_password: ChangeMeImmediately
database:
password: db-password-here
max_idle_conns: 100
max_open_conns: 900
data_volume: /data/harbor
trivy:
ignore_unfixed: true
skip_update: false
insecure: false
log:
level: info
local:
rotate_count: 50
rotate_size: 200M
location: /var/log/harbor
# Install with Trivy scanner
./install.sh --with-trivy
# Verify
docker compose ps
Output:
NAME STATUS PORTS
harbor-core running
harbor-db running 5432/tcp
harbor-jobservice running
harbor-log running 127.0.0.1:1514->10514/tcp
harbor-portal running
harbor-redis running
harbor-registryctl running
nginx running 0.0.0.0:80->8080/tcp, 0.0.0.0:443->8443/tcp
trivy-adapter running
Configuring Replication
Harbor can replicate images to ECR, GHCR, Docker Hub, or another Harbor instance. This is useful for pushing images from your internal registry to a cloud registry for deployment:
# Create a replication rule via Harbor API
curl -X POST "https://registry.internal.company.com/api/v2.0/replication/policies" \
-H "Content-Type: application/json" \
-u "admin:password" \
-d '{
"name": "replicate-to-ecr",
"src_registry": null,
"dest_registry": {"id": 1},
"dest_namespace": "production",
"trigger": {"type": "event_based"},
"filters": [
{"type": "name", "value": "myapp/**"},
{"type": "tag", "value": "v*"}
],
"enabled": true
}'
Complete Working Example
Here is a complete CI/CD pipeline that builds a Node.js application, scans for vulnerabilities, tags with semver and git SHA, pushes to GHCR, and deploys with image digest pinning.
Dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
# Production stage
FROM node:20-alpine
RUN apk add --no-cache dumb-init
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
GitHub Actions Pipeline
name: Build, Scan, Push, Deploy
on:
push:
branches: [main]
tags: ['v*.*.*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-scan-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-version: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU for multi-arch
uses: docker/setup-qemu-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
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=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
- name: Fail on critical vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: table
exit-code: 1
severity: CRITICAL
ignore-unfixed: true
deploy:
needs: build-scan-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy with digest pinning
run: |
echo "Deploying image with digest: ${{ needs.build-scan-push.outputs.image-digest }}"
# Update Kubernetes deployment with exact digest
kubectl set image deployment/myapp \
myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-scan-push.outputs.image-digest }}
# Wait for rollout
kubectl rollout status deployment/myapp --timeout=300s
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
Deployment Verification Script
This Node.js script verifies that the running container matches the expected digest:
var execSync = require("child_process").execSync;
var http = require("http");
var EXPECTED_DIGEST = process.env.EXPECTED_DIGEST;
var HEALTH_URL = process.env.HEALTH_URL || "http://localhost:3000/health";
function verifyDeployment(callback) {
// Check container is running with expected image
var inspectCmd = "docker inspect --format='{{.Image}}' $(docker ps -q --filter ancestor=" +
process.env.IMAGE_NAME + ")";
try {
var runningDigest = execSync(inspectCmd).toString().trim();
console.log("Running image digest:", runningDigest);
} catch (err) {
return callback(new Error("Container not found or not running"));
}
// Health check
http.get(HEALTH_URL, function(res) {
var body = "";
res.on("data", function(chunk) { body += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
console.log("Health check passed:", body);
callback(null, { healthy: true, digest: runningDigest });
} else {
callback(new Error("Health check failed with status: " + res.statusCode));
}
});
}).on("error", function(err) {
callback(new Error("Health check request failed: " + err.message));
});
}
verifyDeployment(function(err, result) {
if (err) {
console.error("Deployment verification FAILED:", err.message);
process.exit(1);
}
console.log("Deployment verified successfully");
console.log(" Digest:", result.digest);
console.log(" Healthy:", result.healthy);
});
Common Issues & Troubleshooting
1. Docker Hub Rate Limiting
Error response from daemon: toomanyrequests: You have reached your pull rate limit.
You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limit
This hits anonymous users at 100 pulls per 6 hours. Fix it by authenticating in your CI pipeline and setting up a pull-through cache. Every docker pull in your Dockerfile's FROM line counts against the limit.
# Check your current rate limit status
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/node:pull" | jq -r .token)
curl -sI -H "Authorization: Bearer $TOKEN" "https://registry-1.docker.io/v2/library/node/manifests/20-alpine" | grep ratelimit
# Output:
# ratelimit-limit: 100;w=21600
# ratelimit-remaining: 87;w=21600
2. ECR Token Expiration
Error saving credentials: error storing credentials - err: exit status 1, out: `error storing credentials - err: exit status 1`
no basic auth credentials
ECR tokens expire after 12 hours. If your CI job runs longer than that (rare but possible with large matrix builds), the token will expire mid-push. Refresh the token immediately before each push step, not just at the start of the pipeline.
# Re-authenticate right before push
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:1.4.2
3. Manifest Unknown or Layer Not Found
Error response from daemon: manifest for ghcr.io/myorg/myapp:1.4.2 not found: manifest unknown
This usually means the tag was deleted by a retention policy, the push was interrupted, or you are pointing at the wrong registry region. For multi-arch images, it can also mean the manifest list was pushed but one of the platform-specific manifests failed.
# Verify the manifest exists
docker manifest inspect ghcr.io/myorg/myapp:1.4.2
# For ECR, check if the image exists
aws ecr describe-images --repository-name myapp --image-ids imageTag=1.4.2
# If using multi-arch, check each platform
docker buildx imagetools inspect ghcr.io/myorg/myapp:1.4.2
4. Push Denied Due to Permissions
denied: requested access to the resource is denied
unauthorized: authentication required
This is the most common issue when setting up a new registry. For GHCR, make sure your GITHUB_TOKEN has packages:write scope and the workflow has permissions.packages: write. For ECR, verify your IAM policy includes ecr:PutImage and ecr:InitiateLayerUpload. For ACR, check that the service principal has AcrPush role.
# GHCR: verify token scopes
curl -sI -H "Authorization: Bearer $GITHUB_TOKEN" https://ghcr.io/v2/ | grep -i scope
# ECR: test IAM permissions
aws ecr get-authorization-token
# If this fails, your IAM role/user lacks ecr:GetAuthorizationToken
# ACR: check role assignments
az role assignment list --scope /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerRegistry/registries/{registry}
5. Image Size Exploding After npm install
Step 5/8 : RUN npm install
---> Running in a1b2c3d4e5f6
added 847 packages in 32s
Final image size: 1.2GB
Your .dockerignore is missing or incomplete. Without it, COPY . . sends node_modules, .git, test fixtures, and everything else into the build context. Fix your .dockerignore:
node_modules
.git
.github
*.md
test
coverage
.env*
.DS_Store
npm-debug.log
After fixing, image size typically drops from 1.2GB to 150-200MB with a proper multi-stage build.
Best Practices
Pin base images by digest, not tag.
FROM node:20-alpine@sha256:abc123...guarantees reproducible builds. The20-alpinetag is mutable and can change when a new patch is released, breaking your build unexpectedly.Never push
latestfrom local machines. Reserve thelatesttag for automated CI builds from the main branch. If anyone can pushlatestmanually, you lose traceability. Enforce this with registry permissions.Scan images in CI and block deployments on critical vulnerabilities. Set
exit-code: 1on your scanner for CRITICAL severity. HIGH severity should generate alerts but not block -- otherwise you will be stuck waiting for upstream patches on every build.Implement retention policies on day one. Do not wait until your registry bill shocks you. Delete untagged images after 24 hours, SHA/PR images after 14 days, and keep only the last 15-20 production releases. Automate this with ECR lifecycle policies, scheduled Actions workflows, or Harbor's built-in garbage collection.
Use digest pinning in production deployments. Tags are mutable pointers. Digests are content-addressable and immutable. Deploy with
myapp@sha256:abc123...instead ofmyapp:1.4.2to guarantee that what you tested is exactly what runs in production.Set up a pull-through cache for public registries. A single Docker Registry instance configured as a mirror eliminates Docker Hub rate limits, speeds up builds, and removes a network dependency. This pays for itself the first time Docker Hub has an outage during your deploy window.
Standardize on a single base image across all services. When every microservice uses
node:20-alpine, the base layers are stored once in your registry and cached once on each node. Mixed base images multiply storage costs and cold-start pull times.Use separate repositories per service, not per environment. Structure as
registry/myappwith tags1.4.2,staging,production-- notregistry/production/myappandregistry/staging/myapp. Tags are cheap; repository proliferation creates management overhead and complicates retention policies.Log and audit all push and pull events. Every registry supports access logging. Route these logs to your monitoring system. When a production incident happens, you need to answer "who pushed this image and when" in seconds, not hours.
References
- Docker Registry HTTP API V2 Specification
- OCI Distribution Specification
- AWS ECR Lifecycle Policies
- GitHub Container Registry Documentation
- Azure Container Registry Best Practices
- Harbor Documentation
- Trivy Vulnerability Scanner
- Docker Buildx Multi-Platform Builds
- DigitalOcean Container Registry
- Sigstore Cosign for Image Signing
