status: accepted date: 2025-01-28 decision-makers: Solutions Architect πŸ›οΈ, Pack Leader 🐺, Code Puppy 🐢 consulted: Experience Architect 🎨 (deployment UX), Web Puppy πŸ•΅οΈ (cost research) informed: All engineering teams, DevOps relates-to: INFRA-004

Context and Problem Statement

The Azure Governance Platform requires a container registry for storing Docker images used in App Service deployments. Initially, we used Azure Container Registry (ACR) as the native Azure solution. However, as the project matured, several friction points emerged:

Cost Concerns: ACR Standard tier costs approximately $5/day ($150/month) for a single region, even with minimal image storage. For a pre-revenue SaaS product, this is significant operational overhead.

Authentication Complexity: ACR requires service principal credentials or admin passwords in deployment pipelines. This conflicts with our evolving zero-secrets architecture (see ADR-0007).

CI/CD Integration: GitHub Actions workflows required additional secrets (ACR_USERNAME, ACR_PASSWORD) and Azure CLI authentication steps to push images.

Vendor Lock-in: Using ACR creates an Azure-specific dependency for a component (container storage) that doesn't require Azure-specific features.

How should we host container images to minimize cost while maintaining security and simplifying CI/CD?

Decision Drivers

  • Cost (K.O. criterion for pre-revenue): Must be free or significantly cheaper than ACR ($150/month)
  • Security: Must align with zero-secrets architecture; no long-lived credentials in CI/CD
  • GitHub integration: Native integration with existing GitHub Actions workflows
  • Multi-tenant readiness: Must support image distribution to multiple Azure subscriptions
  • Azure compatibility: Azure App Service must be able to pull images from chosen registry
  • Implementation effort: Migration must be completable in < 1 day

Considered Options

  1. GitHub Container Registry (GHCR) β€” GitHub's native container registry, free for public repos, integrated with GitHub Actions via GITHUB_TOKEN
  2. Azure Container Registry (ACR) with Basic tier β€” Stay on ACR but downgrade to Basic ($0.17/day) with reduced features
  3. Docker Hub β€” Public registry with free tier for open source projects
  4. Self-hosted registry β€” Deploy our own container registry on Azure compute

Decision Outcome

Chosen option: "GitHub Container Registry (GHCR)", because it is completely free for public repositories, eliminates all authentication secrets (uses GITHUB_TOKEN), has native GitHub Actions integration, and supports Azure App Service deployment via managed identity.

Option 2 (ACR Basic) was rejected because it still costs ~$5/month and doesn't solve the authentication complexity problem.

Option 3 (Docker Hub) was rejected because it requires separate authentication management and doesn't integrate as cleanly with GitHub Actions.

Option 4 (Self-hosted) was rejected because it creates operational burden and security responsibilities we don't need.

Migration Summary

Aspect Before (ACR) After (GHCR) Savings
Monthly cost ~$150 $0 $1800/year
Authentication Service principal secrets GITHUB_TOKEN (ephemeral) Zero secrets
CI/CD complexity Azure login + ACR login + push Direct push 50% reduction
Azure compatibility Native Requires managed identity Same capability

Architecture

Before (ACR):

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ GitHub Actions  │────▢│ ACR Login   │────▢│ Azure Container β”‚
β”‚ Workflow        β”‚     β”‚ (secrets)   β”‚     β”‚ Registry        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ACR admin credentials stored in GitHub Secrets                 β”‚
β”‚ Rotate quarterly (operational burden)                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

After (GHCR):

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ GitHub Actions  │────▢│ GITHUB_TOKEN    │────▢│ GitHub Containerβ”‚
β”‚ Workflow        β”‚     β”‚ (auto-injected) β”‚     β”‚ Registry (GHCR) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Zero secrets required                                           β”‚
β”‚ Token auto-generated by GitHub, 15-minute lifetime              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

App Service Pull:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Azure App       │────▢│ Managed Identity  │────▢│ GHCR (pull)     β”‚
β”‚ Service         β”‚     β”‚ (UAMI)          β”‚     β”‚ Public image    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Implementation Details

  1. GitHub Actions Workflow Changes:

    # Before (ACR)
    - name: Login to ACR
      uses: azure/docker-login@v1
      with:
        login-server: myregistry.azurecr.io
        username: ${{ secrets.ACR_USERNAME }}
        password: ${{ secrets.ACR_PASSWORD }}
    
    # After (GHCR)
    - name: Login to GHCR
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}  # Auto-injected, no setup!
    
  2. Image Tag Changes:

    # Before
    myregistry.azurecr.io/azure-governance-platform:staging
    
    # After
    ghcr.io/tygranlund/azure-governance-platform:staging
    
  3. Bicep Template Changes:

    // App Service container configuration
    properties: {
      siteConfig: {
        linuxFxVersion: 'DOCKER|ghcr.io/tygranlund/azure-governance-platform:${environment}'
      }
    }
    
  4. Migration Script: scripts/migrate-to-ghcr.sh handles:

    • Backfill existing images to GHCR
    • Update all environment configurations
    • Validate App Service can pull from GHCR
    • Cleanup ACR images after validation

Consequences

Good:

  • Cost: $1800/year savings (enough to fund other infrastructure)
  • Security: Zero secrets in CI/CD or configuration
  • Simplicity: GitHub-native workflow, no Azure-specific steps for container push
  • Auditability: All container pushes visible in GitHub security tab
  • Compatibility: Azure App Service supports GHCR via managed identity or public pulls

Bad:

  • Public images: GHCR free tier requires public repositories (acceptable for open-core model)
  • Azure dependency reduction: While good for portability, some Azure-native features (geo-replication, content trust) are lost
  • Migration downtime: 5-minute window to update App Service container settings

Confirmation

Validation performed during migration:

  1. Image push validation: docker push ghcr.io/tygranlund/azure-governance-platform:test
  2. App Service pull validation: Deploy to staging slot, verify container starts
  3. Integration test: Full E2E test suite passing against GHCR-deployed instance
  4. Cleanup: ACR registry deleted after 7-day validation period

Migration completed January 28, 2025. Runbook: docs/runbooks/acr-to-ghcr-migration.md

STRIDE Security Analysis

Threat Category Risk Level Mitigation
Spoofing Low GHCR images are tied to GitHub repository; only authorized workflows can push
Tampering Low GITHUB_TOKEN is ephemeral and scoped to the workflow run; no long-lived credentials to compromise
Repudiation Low GitHub audit log records all package publish events; signed commits required for workflow triggers
Information Disclosure Low Public repository and images align with open-core model; no secrets in image layers
Denial of Service Medium GHCR has rate limits (1000 pulls/hour unauthenticated); mitigated by Azure managed identity for authenticated pulls
Elevation of Privilege Low GITHUB_TOKEN has minimal scope (packages:write) and cannot access other repositories or workflows

Overall Security Posture: GHCR improves security posture by eliminating persistent credentials. The ephemeral GITHUB_TOKEN with minimal scope reduces attack surface compared to ACR service principals with long-lived secrets.

Pros and Cons of the Options

Option 1: Stay on ACR Standard

  • Good, because geo-replication and content trust features available
  • Bad, because $150/month for pre-revenue product is unsustainable
  • Bad, because requires ongoing credential rotation
  • Bad, because Azure-specific lock-in without using Azure-specific features

Option 2: ACR Basic Tier

  • Good, because reduces cost to ~$5/month
  • Bad, because still requires secrets and credential management
  • Bad, because Basic tier has storage and bandwidth limits that may constrain growth
  • Bad, because doesn't solve the fundamental authentication complexity

Option 3: Docker Hub

  • Good, because free tier available for open source
  • Bad, because requires separate Docker Hub authentication
  • Bad, because additional vendor to manage (rate limits, availability)
  • Bad, because doesn't integrate as cleanly with GitHub Actions

Option 4: Self-Hosted Registry

  • Good, because full control over infrastructure
  • Bad, because creates operational burden (backups, updates, monitoring)
  • Bad, because security responsibility shifts to our team
  • Bad, because not cost-effective for expected scale

More Information

  • Migration runbook: docs/runbooks/acr-to-ghcr-migration.md
  • GHCR setup guide: docs/GHCR_SETUP.md
  • Related authentication ADR: docs/decisions/adr-0007-auth-evolution.md
  • Cost analysis: Saved $1800/year, redirected to Azure SQL Free Tier (see ADR-0009)

Template Version: MADR 4.0 (September 2024) with STRIDE Security Analysis
Last Updated: 2025-01-28
Maintained By: Solutions Architect πŸ›οΈ