ECR + GitHub Actions
Pattern for deploying container images using AWS ECR with OIDC authentication and per-project repository management.
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-passworddirectly)
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
- OIDC Roles (central): Pull and push roles managed in
infra/global/ecr/— shared across all projects - ECR Repositories (per-project): Created in each project's Terraform with project-specific lifecycle policies
- GitHub Workflows: Thin wrappers around Taskfile tasks that assume AWS roles via OIDC
- 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:
- Go to repository Settings → Secrets and variables → Actions
- Add secret:
- Name:
AWS_ACCOUNT_ID - Value:
271966353206
- Name:
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.
Recommended Repository Settings
| Setting | Value | Rationale |
|---|---|---|
image_tag_mutability | IMMUTABLE | Prevents overwriting released versions |
scan_on_push | true | Catches vulnerabilities early |
| Lifecycle: tagged images | Keep last 10 | Balances storage cost vs rollback capability |
| Lifecycle: untagged images | Expire after 7 days | Cleans up intermediate build layers |
These are recommendations, not requirements. Projects may adjust based on their needs.
Related Patterns
- CodeArtifact + GitHub Actions — Similar OIDC pattern for package registries
- GitHub Actions Workflows — How to structure CI/CD workflows
- Taskfile Contract — Taskfile as operational interface
- Infrastructure Layout — Where infrastructure lives (central vs per-project)
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
- AWS ECR Documentation
- GitHub OIDC with AWS
- aws-actions/configure-aws-credentials
- aws-actions/amazon-ecr-login
- ECR Lifecycle Policies
Status: Draft — pending review and publication to engineering-handbook
Target path: patterns/organizational/ecr-github-actions.mdLast Updated: 2026-02-08