Containerization

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, aws CLI, az CLI, or doctl depending 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

  1. Multi-stage builds -- Reduce image size from 1GB to 150MB. Smaller images mean less storage and faster transfers.

  2. 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.

  3. Aggressive retention policies -- Delete dev/PR images after 14 days. Keep only the last 10-20 production releases.

  4. 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. The 20-alpine tag is mutable and can change when a new patch is released, breaking your build unexpectedly.

  • Never push latest from local machines. Reserve the latest tag for automated CI builds from the main branch. If anyone can push latest manually, you lose traceability. Enforce this with registry permissions.

  • Scan images in CI and block deployments on critical vulnerabilities. Set exit-code: 1 on 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 of myapp:1.4.2 to 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/myapp with tags 1.4.2, staging, production -- not registry/production/myapp and registry/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

Powered by Contentful