Organizational

ECR + GitHub Actions

Pattern for deploying container images using AWS ECR with OIDC authentication and per-project repository management.

Production

Problem

Teams need to securely build, push, and pull Docker container images from GitHub Actions workflows without managing AWS credentials as secrets, while maintaining per-project control over repository configuration (lifecycle policies, scanning, retention).

Context

When to Use This Pattern

  • Deploying containerized services (ECS, AppRunner, Bedrock AgentCore, Lambda container images)
  • Building and publishing Docker images from CI/CD pipelines
  • Pulling private base images or service images in workflows
  • Projects requiring container image scanning and lifecycle management

When NOT to Use This Pattern

  • Projects that only consume public container images (use Docker Hub directly)
  • Self-hosted runners with IAM instance profiles (use instance profiles instead)
  • Serverless-only projects with no container dependencies
  • Local development with personal AWS credentials (use aws ecr get-login-password directly)

Solution

Use GitHub OIDC (OpenID Connect) to authenticate GitHub Actions workflows to AWS ECR without storing long-lived credentials. OIDC roles are managed centrally in ontopix/infra, while ECR repositories are created per-project in each project's own Terraform configuration.

Core Components

  1. OIDC Roles (central): Pull and push roles managed in infra/global/ecr/ — shared across all projects
  2. ECR Repositories (per-project): Created in each project's Terraform with project-specific lifecycle policies
  3. GitHub Workflows: Thin wrappers around Taskfile tasks that assume AWS roles via OIDC
  4. Taskfile Tasks: Reusable container operations (build, push, pull) that can run locally and in CI

Architecture Decision

ECR follows a hybrid management model (see ADR-001):

  • Central (ontopix/infra): OIDC roles for push/pull access — org-wide, rarely changes
  • Per-project: ECR repositories, lifecycle policies, scanning config — project-specific, team-owned

This differs from CodeArtifact (fully centralized) because ECR repositories are inherently project-scoped, and teams benefit from controlling their own retention and scanning policies.

Structure

Central Infrastructure (ontopix/infra)

infra/
└── global/
    └── ecr/
        ├── iam.tf            # OIDC roles and policies
        ├── outputs.tf        # Role ARNs and registry URL
        ├── variables.tf      # (empty — uses data sources)
        └── README.md         # Developer guide

Per-Project Infrastructure

my-project/
└── .infra/
    └── ecr.tf              # Repository, lifecycle, scanning

Naming Convention

ECR repositories follow {project}/{image}:

ontopix/some-service
anotherproject/worker

This mirrors the GitHub organization structure and prevents namespace collisions.

Implementation

Step 1: Create ECR Repository (Per-Project Terraform)

Add an ECR repository to your project's Terraform configuration:

# .infra/ecr.tf

resource "aws_ecr_repository" "my_service" {
  name                 = "myproject/my-service"
  image_tag_mutability = "IMMUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }

  tags = {
    Project   = "myproject"
    ManagedBy = "terraform"
  }
}

resource "aws_ecr_lifecycle_policy" "my_service" {
  repository = aws_ecr_repository.my_service.name

  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Keep last 10 tagged images"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["v"]
          countType     = "imageCountMoreThan"
          countNumber   = 10
        }
        action = {
          type = "expire"
        }
      },
      {
        rulePriority = 2
        description  = "Remove untagged images after 7 days"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 7
        }
        action = {
          type = "expire"
        }
      }
    ]
  })
}

output "ecr_repository_url" {
  description = "ECR repository URL for my-service"
  value       = aws_ecr_repository.my_service.repository_url
}

Apply with:

cd .infra
task infra:plan   # Review changes
# After review and approval
task infra:apply

Step 2: Configure Repository Taskfile

Add container tasks to your repository's Taskfile.yaml:

# Taskfile.yaml
version: '3'

vars:
  AWS_ACCOUNT_ID:
    sh: aws sts get-caller-identity --query Account --output text 2>/dev/null || echo "unknown"
  AWS_REGION: eu-central-1
  ECR_REGISTRY: "{{.AWS_ACCOUNT_ID}}.dkr.ecr.{{.AWS_REGION}}.amazonaws.com"
  ECR_REPOSITORY: myproject/my-service

tasks:
  docker:login:
    desc: Login to ECR (requires AWS credentials)
    cmds:
      - |
        aws ecr get-login-password --region {{.AWS_REGION}} | \
          docker login --username AWS --password-stdin {{.ECR_REGISTRY}}
    preconditions:
      - sh: command -v aws
        msg: "AWS CLI is required"
      - sh: command -v docker
        msg: "Docker is required"

  docker:build:
    desc: Build Docker image
    cmds:
      - docker build -t {{.ECR_REGISTRY}}/{{.ECR_REPOSITORY}}:{{.CLI_ARGS | default "latest"}} .
    preconditions:
      - sh: test -f Dockerfile
        msg: "Dockerfile not found"

  docker:push:
    desc: Push Docker image to ECR
    cmds:
      - task: docker:login
      - docker push {{.ECR_REGISTRY}}/{{.ECR_REPOSITORY}}:{{.CLI_ARGS | default "latest"}}

  docker:pull:
    desc: Pull Docker image from ECR
    cmds:
      - task: docker:login
      - docker pull {{.ECR_REGISTRY}}/{{.ECR_REPOSITORY}}:{{.CLI_ARGS | default "latest"}}

  release:docker:
    desc: Build and push Docker image for release
    cmds:
      - task: docker:build
        vars: { CLI_ARGS: "{{.GIT_TAG}}" }
      - task: docker:push
        vars: { CLI_ARGS: "{{.GIT_TAG}}" }
    vars:
      GIT_TAG:
        sh: git describe --tags --exact-match 2>/dev/null || echo "dev"

Step 3: Create GitHub Actions Workflows

3.1: CI Workflow (Pull access for builds/tests)

# .github/workflows/ci.yaml
name: CI

on:
  push:
    branches: [master, main]
  pull_request:
    branches: [master, main]

permissions:
  id-token: write
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActions-ECR-PullRole
          aws-region: eu-central-1

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Setup Task
        uses: arduino/setup-task@v2

      # Pull base images or service dependencies if needed
      - name: Pull Dependencies
        run: task docker:pull -- latest

      - name: Run Tests
        run: task test:all

3.2: Release Workflow (Push access for publishing)

# .github/workflows/release.yaml
name: Release

on:
  push:
    tags:
      - 'v*'

permissions:
  id-token: write
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActions-ECR-PushRole
          aws-region: eu-central-1

      - name: Login to ECR
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v2

      - name: Setup Task
        uses: arduino/setup-task@v2

      - name: Build and Push
        run: task release:docker

Step 4: Configure GitHub Repository Secrets

Add the AWS account ID as a GitHub repository secret:

  1. Go to repository SettingsSecrets and variablesActions
  2. Add secret:
    • Name: AWS_ACCOUNT_ID
    • Value: 271966353206

Note: This is the ONLY secret required. No AWS access keys needed.

Step 5: Local Development Setup

For local development, developers authenticate using their personal AWS credentials:

# Configure AWS CLI (one-time setup)
aws configure --profile ontopix

# Login to ECR and build
task docker:login
task docker:build -- v1.0.0-dev

# Push (if needed for testing)
task docker:push -- v1.0.0-dev

Applies Principles

  • Security by Design: OIDC removes long-lived credentials, reducing attack surface
  • Least Privilege: Separate roles for pull/push with minimal required permissions
  • Team Autonomy: Per-project repository ownership with centralized access control
  • Reproducibility: Same Taskfile tasks work locally and in CI
  • Separation of Concerns: Central infra manages access, projects manage their repos
  • Audit Trail: CloudTrail logs all ECR operations with GitHub actor identity

Consequences

Benefits

  • No Credentials in GitHub Secrets: OIDC eliminates long-lived AWS access keys
  • Automatic Credential Rotation: OIDC tokens expire after use
  • Fine-Grained Access Control: Trust policies restrict by repo, branch, tag, or environment
  • Team Ownership: Projects control lifecycle, scanning, and retention independently
  • Image Security: Scan-on-push catches vulnerabilities before deployment
  • Tag Immutability: Prevents overwriting released versions

Trade-offs

  • Split Terraform State: OIDC roles and repositories live in separate state files (same S3 bucket, different keys)
  • No Central Policy Enforcement: Teams must follow recommended patterns from infra/global/ecr/README.md
  • Initial Setup Per-Project: Each new project needs its own ECR Terraform configuration

Limitations

  • Self-Hosted Runners: If using self-hosted runners, consider instance profiles instead
  • Cross-Account Access: Pattern needs registry-level policies for multi-account pulls (future enhancement)
  • Non-GitHub CI: Different OIDC configuration required for GitLab, CircleCI, etc.
SettingValueRationale
image_tag_mutabilityIMMUTABLEPrevents overwriting released versions
scan_on_pushtrueCatches vulnerabilities early
Lifecycle: tagged imagesKeep last 10Balances storage cost vs rollback capability
Lifecycle: untagged imagesExpire after 7 daysCleans up intermediate build layers

These are recommendations, not requirements. Projects may adjust based on their needs.

References

Ontopix Resources

  • Central OIDC roles: ontopix/infra/global/ecr/
  • Architecture decision: ADR-001: Hybrid ECR Management
  • OIDC roles: GitHubActions-ECR-PullRole, GitHubActions-ECR-PushRole
  • Registry: 271966353206.dkr.ecr.eu-central-1.amazonaws.com

External Documentation


Status: Draft — pending review and publication to engineering-handbook Target path: patterns/organizational/ecr-github-actions.mdLast Updated: 2026-02-08