Docker Hub, GHCR, ACR, ECR. Image tagging strategies that prevent deployment disasters. Trivy vulnerability scanning in CI. Image signing with cosign. Retention policies to control storage costs.
docker pull nginx:alpine → pulling from Docker Hub.ghcr.io/username/myapp:v1.0.0acrinfra.azurecr.io/myapp:v1.0.0123456789.dkr.ecr.us-east-1.amazonaws.com/myapp| Registry | Free? | Best for | Auth in CI |
|---|---|---|---|
| Docker Hub | Limited | Public images | Secret |
| GHCR | ✅ Yes | GitHub repos | GITHUB_TOKEN |
| ACR | Paid | Azure/AKS | Managed ID |
| ECR | 500MB free | AWS/EKS | IAM role |
npm install express → pulls from npm registrydocker pull nginx → pulls from Docker Hub# Monday morning: 3 AKS nodes pull image kubectl set image deployment/app app=myapp:latest # Node 1: already has latest cached (from Friday) # Node 2: pulls latest (NEW version, deployed this morning) # Node 3: pulls latest (NEW version) # # → Node 1 running v1.2.0 # → Node 2 running v1.3.0 # → Node 3 running v1.3.0 # # RESULT: 3 nodes, 2 different versions # → Inconsistent behaviour, hard to debug # → "But it works on my machine!" # Rollback: # → to which 'latest'? There are two! # → you can't reliably roll back :latest
kubectl set image deployment/app \ app=myapp:sha-a3f7c2d # Every node pulls SHA sha-a3f7c2d # sha-a3f7c2d ALWAYS points to the same image # All 3 nodes: identical version # Rollback: # → deploy sha-b2e6d1c (previous SHA) # → always reproducible
| :latest | Mutable. Avoid in production. OK for local dev only. |
| :v1.2.3 | SemVer. Explicit, immutable, human-readable. Tag on releases. |
| :sha-a3f7c2d | Git SHA. Every commit gets unique tag. Perfect traceability. |
| :v1.2.3-sha-a3f7c2d ⭐ | Combined. Human-readable + traceable. Best of both worlds. |
IMAGE="ghcr.io/user/myapp" VERSION="1.2.3" SHA=$(git rev-parse --short HEAD) # Build with all tags at once docker build \ -t $IMAGE:$VERSION \ -t $IMAGE:$VERSION-$SHA \ -t $IMAGE:latest \ . # Push all tags docker push $IMAGE:$VERSION docker push $IMAGE:$VERSION-$SHA docker push $IMAGE:latest # Kubernetes deployment uses SHA: # image: ghcr.io/user/myapp:sha-a3f7c2d
# Install Trivy brew install aquasecurity/trivy/trivy # Mac apt-get install trivy # Ubuntu/Debian trivy --version # === Basic scan — see ALL vulnerabilities === trivy image myapp:v1.0.0 # Example output: # Total: 27 (CRITICAL: 0, HIGH: 2, MEDIUM: 8, LOW: 17) # # ┌──────────────┬────────────────┬──────────┬────────────┐ # │ Library │ Vulnerability │ Severity │ Fixed In │ # ├──────────────┼────────────────┼──────────┼────────────┤ # │ openssl │ CVE-2023-5678 │ HIGH │ 3.1.5 │ # │ libssl │ CVE-2023-5679 │ HIGH │ 3.1.5 │ # │ zlib │ CVE-2023-1234 │ MEDIUM │ 1.2.13 │ # └──────────────┴────────────────┴──────────┴────────────┘ # === Filter by severity === trivy image --severity HIGH,CRITICAL myapp:v1.0.0 # === Exit code 1 if found (for CI gate) === trivy image --exit-code 1 \ --severity HIGH,CRITICAL \ myapp:v1.0.0 # Exit 0 → no HIGH/CRITICAL → CI passes # Exit 1 → found HIGH/CRITICAL → CI fails # === JSON output for automation === trivy image --format json \ --output results.json \ myapp:v1.0.0 # === Scan a remote image (from GHCR) === trivy image ghcr.io/user/myapp:v1.0.0 # === Scan filesystem (CI — before build) === trivy fs --severity HIGH,CRITICAL . # === Scan IaC files (Terraform, K8s YAML) === trivy config ./k8s/
FROM node:20-alpine → 10 CVEs. Update to latest patch: FROM node:20.11.0-alpine → often 0 CVEs. Run docker pull node:20-alpine to get the latest digest.
# === GHCR: GITHUB_TOKEN (no setup needed) === # In GitHub Actions, just add permissions: permissions: packages: write # === GHCR: PAT for local push === # Create PAT: github.com/settings/tokens # Scopes: write:packages, read:packages, delete:packages echo $GITHUB_PAT | docker login ghcr.io \ -u YOUR_USERNAME \ --password-stdin # === ACR: Service Principal (CI) === az acr login --name acrinfra # Or with credentials: docker login acrinfra.azurecr.io \ -u $SP_CLIENT_ID \ -p $SP_CLIENT_SECRET # === K8s: imagePullSecret for private registry === kubectl create secret docker-registry ghcr-secret \ --docker-server=ghcr.io \ --docker-username=YOUR_USER \ --docker-password=$GITHUB_PAT # In pod spec: spec: imagePullSecrets: - name: ghcr-secret # === Image signing with cosign === # Install: brew install cosign cosign generate-key-pair cosign sign --key cosign.key \ ghcr.io/user/myapp:v1.0.0 # Verify signature: cosign verify --key cosign.pub \ ghcr.io/user/myapp:v1.0.0 # K8s: Policy Controller enforces: only signed images run
# Delete images older than 30 days az acr config retention update \ --name acrinfra \ --status enabled \ --days 30 \ --type UntaggedManifests # Keep only last 10 tagged images per repo az acr task create \ --name cleanup-old-images \ --registry acrinfra \ --cmd "acr purge --filter 'myapp:.*' \ --keep 10 --untagged" \ --schedule "0 1 * * *"
# === Login === az acr login --name acrinfra # uses az CLI credentials — no password in shell! # === Build + push directly in Azure === az acr build \ --registry acrinfra \ --image myapp:v1.0.0 \ . # Builds in Azure, no local Docker needed! # Great for CI runners without Docker daemon # === List repositories === az acr repository list \ --name acrinfra \ --output table # === List tags for a repo === az acr repository show-tags \ --name acrinfra \ --repository myapp \ --orderby time_desc # === ACR Defender: scan results === az security assessment list \ --query "[?contains(name,'acr')]" # === GitHub Actions → ACR (Managed Identity) === # In your workflow: - uses: azure/login@v2 with: client-id: \${{ secrets.AZURE_CLIENT_ID }} tenant-id: \${{ secrets.AZURE_TENANT_ID }} subscription-id: \${{ secrets.AZURE_SUBSCRIPTION_ID }} - uses: azure/docker-login@v1 with: login-server: acrinfra.azurecr.io username: \${{ secrets.ACR_USERNAME }} password: \${{ secrets.ACR_PASSWORD }}
node:20-alpine is patched.az aks update -n myaks -g myrg --attach-acr acrinfraAcrPull role on ACR. Pods can pull from ACR without configuring imagePullSecrets
name: Container CI/CD on: push: branches: [ main ] push: tags: [ 'v*.*.*' ] ← also runs on git tag env: REGISTRY: ghcr.io IMAGE_NAME: \${{ github.repository }} jobs: build-scan-push: runs-on: ubuntu-latest permissions: contents: read packages: write security-events: write ← for uploading scan results steps: - uses: actions/checkout@v4 - name: Set up Buildx uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: \${{ github.actor }} password: \${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/\${{ env.IMAGE_NAME }} tags: | type=sha type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable=\${{ github.ref == 'refs/heads/main' }} - name: Build image (don't push yet) uses: docker/build-push-action@v5 with: context: . push: false ← build only, scan first! load: true ← load into local Docker tags: \${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max - name: Scan with Trivy (BLOCK on CRITICAL) uses: aquasecurity/trivy-action@master with: image-ref: ghcr.io/\${{ env.IMAGE_NAME }}:latest format: sarif output: trivy-results.sarif severity: CRITICAL,HIGH exit-code: '1' ← fail if CRITICAL/HIGH found - name: Upload scan results to GitHub if: always() uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results.sarif - name: Push to GHCR (only if scan passed) uses: docker/build-push-action@v5 with: context: . push: true ← now push tags: \${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max
push: false + load: true on the first build step. Build locally, scan locally, then push only if clean.
SARIF (Static Analysis Results Interchange Format) uploads scan results to GitHub's Security tab. You can see:
Repo → Security → Code scanning alerts.
Authenticate → multi-tag build → push to GHCR → scan with Trivy → add scan gate to CI pipeline
write:packages, read:packages. Export as GITHUB_PAT. Login: echo $GITHUB_PAT | docker login ghcr.io -u USERNAME --password-stdinVERSION=1.0.0, get SHA=$(git rev-parse --short HEAD). Build with three tags at once: $IMAGE:$VERSION, $IMAGE:$VERSION-$SHA, $IMAGE:latest.trivy image --severity HIGH,CRITICAL my-devops-app:v1.0.0. Compare alpine-based image vs node:20 (full). See the CVE count difference. Use --exit-code 1 to test the gate.docker.yml: add push: false + load: true to build step, then aquasecurity/trivy-action scan step, then second build step with push: true. Push → watch scan gate in Actions.# === Step 1: Authenticate === export GITHUB_PAT="ghp_yourtoken" export GITHUB_USERNAME="priyakaviyil" echo $GITHUB_PAT | docker login ghcr.io \ -u $GITHUB_USERNAME \ --password-stdin # Login Succeeded # === Step 2: Build with all tags === IMAGE="ghcr.io/$GITHUB_USERNAME/my-devops-app" VERSION="1.0.0" SHA=$(git rev-parse --short HEAD) echo "Building $IMAGE:$VERSION-$SHA" docker build \ -t $IMAGE:$VERSION \ -t $IMAGE:$VERSION-$SHA \ -t $IMAGE:latest \ -f Dockerfile . # === Step 3: Push all tags === docker push $IMAGE:$VERSION docker push $IMAGE:$VERSION-$SHA docker push $IMAGE:latest # Check: github.com/USERNAME/my-devops-app → Packages # You should see: v1.0.0, v1.0.0-abc1234, latest # === Step 4: Scan locally === # Install if needed: # brew install aquasecurity/trivy/trivy trivy image $IMAGE:$VERSION trivy image --severity HIGH,CRITICAL $IMAGE:$VERSION # Test the gate: trivy image --exit-code 1 \ --severity CRITICAL \ $IMAGE:$VERSION echo "Exit code: $?" # 0 = no CRITICAL → CI would pass # 1 = CRITICAL found → CI would fail
name: Docker on: push: branches: [ main ] push: tags: [ 'v*.*.*' ] env: IMAGE: ghcr.io/\${{ github.repository }} jobs: build-scan-push: runs-on: ubuntu-latest permissions: packages: write contents: read steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: \${{ github.actor }} password: \${{ secrets.GITHUB_TOKEN }} # Build WITHOUT pushing - uses: docker/build-push-action@v5 with: context: . push: false load: true tags: \${{ env.IMAGE }}:scan-target cache-from: type=gha cache-to: type=gha,mode=max # Scan — fail on CRITICAL - name: Trivy scan uses: aquasecurity/trivy-action@master with: image-ref: \${{ env.IMAGE }}:scan-target severity: CRITICAL,HIGH exit-code: '1' # Only push if scan passed - name: Push to GHCR uses: docker/build-push-action@v5 with: context: . push: true tags: | \${{ env.IMAGE }}:sha-\${{ github.sha }} \${{ env.IMAGE }}:latest cache-from: type=gha cache-to: type=gha,mode=max
3 questions · 5 minutes · latest tag danger, Trivy, Git SHA tagging
ci: add trivy scan gate before push ✓git tag.
# metadata-action generates tags automatically - id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/\${{ github.repository }} tags: | # Git SHA — always generated type=sha → sha-a3f7c2d # Branch name — on branch push type=ref,event=branch → main # PR number — on pull request type=ref,event=pr → pr-42 # Full SemVer — on git tag v1.2.3 type=semver,pattern={{version}} → 1.2.3 # Minor version — on git tag type=semver,pattern={{major}}.{{minor}} → 1.2 # Major version — on git tag type=semver,pattern={{major}} → 1 # latest — only on main branch type=raw,value=latest,\ enable=\${{ github.ref == 'refs/heads/main' }} # On push to main: # Tags: sha-a3f7c2d, main, latest # On git tag v2.4.1: # Tags: sha-a3f7c2d, 2.4.1, 2.4, 2, latest
# Workflow trigger on: push: tags: [ 'v*.*.*' ] ← semantic version tags # Create and push a release tag: git tag v2.4.1 git push origin v2.4.1 # → Triggers workflow # → metadata-action generates: # 2.4.1, 2.4, 2, sha-xxx, latest # → Image pushed with all tags
az acr config content-trust enable. Never overwrite a tag that's currently deployed.
# === Image scanning === trivy image myapp:latest # local image trivy image ghcr.io/u/app:v1.0.0 # remote image trivy image nginx:alpine # official image # === Severity filtering === trivy image --severity HIGH,CRITICAL myapp trivy image --severity CRITICAL myapp # === CI gate === trivy image --exit-code 1 \ --severity CRITICAL \ myapp:latest # exit 0 = no CRITICAL → pipeline passes # exit 1 = CRITICAL found → pipeline fails # === Output formats === trivy image --format table myapp # default trivy image --format json myapp # machine-readable trivy image --format sarif myapp # GitHub Security tab trivy image --format cyclonedx myapp # SBOM output # === Filesystem scan (before build) === trivy fs . # scan current dir trivy fs --severity HIGH,CRITICAL . # === Repo scan === trivy repo github.com/user/repo # === IaC scan === trivy config ./k8s/ # K8s YAML trivy config ./terraform/ # Terraform trivy config Dockerfile # Dockerfile lint # === Secret scanning === trivy fs --scanners secret . # finds: AWS keys, GitHub tokens, etc. # === Ignore specific CVEs === # Create .trivyignore: # CVE-2023-1234 # accepted risk: no fix available # CVE-2023-5678 # not exploitable in our config
- uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:latest required
format: sarif table|json|sarif
output: trivy.sarif output file
severity: CRITICAL,HIGH filter levels
exit-code: '1' fail CI if found
ignore-unfixed: true skip no-fix CVEs
vuln-type: os,library what to scan
trivyignores: .trivyignore ignore file
ignore-unfixed: true skips CVEs where no patched version exists. Prevents false positives that block CI for issues you can't fix. Review unfixed CVEs separately.
trivy image --format cyclonedx myapp generates an SBOM — a complete list of every package in your image. Required for compliance in financial, healthcare, and government sectors.
| Day | Topic | Key Output | Status |
|---|---|---|---|
| Day 16 | Docker Fundamentals | Namespaces, cgroups, OverlayFS lab | ✅ |
| Day 17 | Writing Dockerfiles | 1.1 GB → 80 MB optimised Dockerfile | ✅ |
| Day 18 | Docker Compose | Node.js + PostgreSQL + Redis local stack | ✅ |
| Day 19 ← TODAY | Container Registries | SemVer+SHA tags + Trivy gate in CI | ✅ |
| Day 20 | Container Capstone | Full container pipeline end-to-end | Tomorrow |
git push → docker.yml triggers: 1. Checkout 2. Setup Buildx 3. Login to GHCR (GITHUB_TOKEN) 4. Build image (push: false, load: true) 5. Trivy scan → exit 1 if CRITICAL ← NEW 6. Push to GHCR with SHA + latest tags ← passes only if scan clean git tag v1.0.0 → push tag → Same pipeline + semantic version tags: v1.0.0, 1.0, 1, sha-xxx, latest
| Registry | Free tier | CI auth | Scanning | Best for |
|---|---|---|---|---|
| Docker Hub | 1 private, rate limits | Secret | Paid tier | Public open-source images |
| GHCR | Unlimited | GITHUB_TOKEN | Dependabot alerts | GitHub repos, open-source, courses ⭐ |
| ACR | Paid (Basic ~$5/mo) | Managed Identity | Defender built-in | Azure/AKS workloads ⭐ |
| ECR | 500 MB/month | IAM role | Inspector | AWS/EKS workloads |
| Artifact Registry (GCR) | 500 MB/month | Workload Identity | Container Analysis | GCP/GKE workloads |
| Problem | Cause | Fix |
|---|---|---|
| GHCR push: denied (403) | Missing permissions: packages: write or PAT missing write:packages scope | Add permissions: packages: write to job. For PAT: ensure write:packages scope selected. |
| Trivy scan fails CI unexpectedly | CVEs found in base image | Update base image: docker pull node:20-alpine. Use ignore-unfixed: true for no-fix CVEs. Add .trivyignore for accepted risks. |
| Image not visible in GHCR Packages tab | Package is private or linked to different repo | GitHub → your profile → Packages. Find the package. Package settings → Change visibility to public if needed. |
| docker pull: unauthorized | Not logged in or token expired | echo $PAT | docker login ghcr.io -u username --password-stdin. Check PAT hasn't expired. |
| Trivy: "unable to pull image" | Image not in local Docker, registry auth needed | Login to registry first. For CI: ensure registry credentials are configured before Trivy scan step. |
| Docker Hub rate limit: 429 Too Many Requests | Unauthenticated CI pulls exhausted | Add Docker Hub login step in CI. Or use GHCR/ACR as pull-through cache for official images. |
| git tag not triggering CI | Workflow only triggers on branch push | Add tag trigger: on: push: tags: ['v*.*.*'] |
| ACR login fails in CI | Service principal credentials expired or wrong | az acr login --name acrinfra to test locally. Regenerate SP credentials if expired. |
# Auth echo $PAT | docker login ghcr.io -u user --password-stdin az acr login --name acrinfra # Build with proper tags IMAGE="ghcr.io/user/myapp" VERSION="1.0.0" SHA=$(git rev-parse --short HEAD) docker build \ -t $IMAGE:$VERSION \ -t $IMAGE:$VERSION-$SHA \ -t $IMAGE:latest . # Push docker push $IMAGE:$VERSION docker push $IMAGE:$VERSION-$SHA docker push $IMAGE:latest # Pull / inspect docker pull $IMAGE:v1.0.0 docker inspect $IMAGE:v1.0.0 # List local images docker images $IMAGE docker image ls --digests $IMAGE # Trivy scan trivy image $IMAGE:v1.0.0 trivy image --severity HIGH,CRITICAL \ --exit-code 1 \ $IMAGE:v1.0.0 # Create release tag git tag v1.0.0 git push origin v1.0.0
# ❌ Avoid in production myapp:latest ← mutable, no traceability # ✅ Use for deployments myapp:sha-a3f7c2d ← immutable, traceable # ✅ Use for releases myapp:v1.2.3 ← human-readable, immutable # ✅ Best for production (both) myapp:v1.2.3-sha-abc ← version + commit # Rollback with SHA: # helm upgrade app chart/ \ # --set image.tag=sha-a3f7c2d
# .trivyignore — CVEs to skip CVE-2023-1234 # no fix available yet CVE-2023-5678 # not exploitable: internal only # Always document WHY you're ignoring!
ci: add trivy scan gate before pushrelease: tag v1.0.0