Platform Engineering
13 min readMay 29, 2026

GitHub Actions CI/CD for EKS: OIDC, ECR, and Helm Deployments Without Static Credentials

Static AWS access keys in GitHub Secrets are a credential leak waiting to happen. OIDC federation lets GitHub Actions assume an IAM role directly — no long-lived credentials stored anywhere. Here's the complete pipeline from code push to EKS deployment.

AJ
Ajeet Yadav
Platform & Cloud Engineer
GitHub Actions CI/CD for EKS: OIDC, ECR, and Helm Deployments Without Static Credentials

Most teams set up their GitHub Actions → EKS pipeline by creating an IAM user, generating access keys, and dropping AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY into GitHub Secrets. It works. It's also a permanent credential that never rotates, lives in your GitHub org settings forever, and if it leaks, gives whoever has it full AWS access until someone notices and rotates it manually.

OIDC federation eliminates this entirely. GitHub Actions can request short-lived AWS credentials directly from STS by proving its identity through an OIDC token. No static keys stored anywhere. The credentials expire after the workflow run ends. If a workflow is compromised, the blast radius is a single run — not your entire AWS account.

This post covers the complete pipeline: OIDC trust setup, ECR multi-arch builds with layer caching, EKS access configuration, and Helm deployments with proper rollback behavior.


OIDC: How It Works

When a GitHub Actions workflow runs, GitHub's OIDC provider issues a JWT token identifying the workflow. This token includes claims like:

  • repository: your-org/your-repo
  • ref: refs/heads/main
  • workflow: deploy
  • environment: production

AWS STS validates this token against the registered OIDC provider and, if the claims match the IAM role's trust policy, issues temporary credentials. The workflow gets an access key, secret key, and session token that expire in ~1 hour.

No secrets stored. No rotation needed. No manual credential management.


Step 1: Register GitHub's OIDC Provider in AWS

bash
# Register GitHub's OIDC provider
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

The --thumbprint-list parameter is required by the CLI but AWS no longer uses it to validate token.actions.githubusercontent.com — as of April 2023, AWS validates this provider using its own CA bundle instead. The value shown is the historical thumbprint; any valid 40-char hex string is accepted.

You only do this once per AWS account.


Step 2: Create the IAM Role

json
1{
2  "Version": "2012-10-17",
3  "Statement": [
4    {
5      "Effect": "Allow",
6      "Principal": {
7        "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
8      },
9      "Action": "sts:AssumeRoleWithWebIdentity",
10      "Condition": {
11        "StringEquals": {
12          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
13        },
14        "StringLike": {
15          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*"
16        }
17      }
18    }
19  ]
20}

Save this as trust-policy.json and create the role:

bash
1aws iam create-role \
2  --role-name github-actions-deploy \
3  --assume-role-policy-document file://trust-policy.json
4
5aws iam put-role-policy \
6  --role-name github-actions-deploy \
7  --policy-name ecr-eks-access \
8  --policy-document file://permissions-policy.json

The sub condition in the trust policy scopes which workflows can assume this role. repo:your-org/your-repo:* allows any ref in that repo. Lock it down to specific environments in production:

json
"StringEquals": {
  "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
}

This means only workflows running against the production GitHub Environment can assume this role. Workflows from feature branches cannot.

Permissions Policy

json
1{
2  "Version": "2012-10-17",
3  "Statement": [
4    {
5      "Effect": "Allow",
6      "Action": [
7        "ecr:GetAuthorizationToken"
8      ],
9      "Resource": "*"
10    },
11    {
12      "Effect": "Allow",
13      "Action": [
14        "ecr:BatchCheckLayerAvailability",
15        "ecr:InitiateLayerUpload",
16        "ecr:UploadLayerPart",
17        "ecr:CompleteLayerUpload",
18        "ecr:PutImage",
19        "ecr:BatchGetImage",
20        "ecr:GetDownloadUrlForLayer"
21      ],
22      "Resource": "arn:aws:ecr:us-east-1:123456789:repository/my-app"
23    },
24    {
25      "Effect": "Allow",
26      "Action": [
27        "eks:DescribeCluster"
28      ],
29      "Resource": "arn:aws:eks:us-east-1:123456789:cluster/my-cluster"
30    }
31  ]
32}

eks:DescribeCluster is the only IAM permission the role needs for EKS — it allows aws eks update-kubeconfig to fetch the cluster endpoint and certificate. Kubernetes API authorization is handled separately by K8s RBAC, not IAM.


Step 3: Grant Kubernetes API Access

The IAM role needs permission to call the Kubernetes API. There are two ways to do this — EKS Access Entries (the current approach, supported in EKS 1.28+) and the legacy aws-auth ConfigMap.

Access entries require the cluster to use API or API_AND_CONFIG_MAP authentication mode. The default depends on how the cluster was created: clusters created via the AWS Console default to API_AND_CONFIG_MAP; clusters created via the API, AWS SDK, or CloudFormation default to CONFIG_MAP. For clusters on CONFIG_MAP, migrate first:

bash
1# Check current authentication mode
2aws eks describe-cluster --name my-cluster --query 'cluster.accessConfig.authenticationMode'
3
4# Migrate from CONFIG_MAP to API_AND_CONFIG_MAP (non-destructive — existing aws-auth entries still work)
5aws eks update-cluster-config \
6  --name my-cluster \
7  --access-config authenticationMode=API_AND_CONFIG_MAP \
8  --region us-east-1
bash
1# Create an access entry for the GitHub Actions IAM role
2aws eks create-access-entry \
3  --cluster-name my-cluster \
4  --principal-arn arn:aws:iam::123456789:role/github-actions-deploy \
5  --type STANDARD \
6  --region us-east-1
7
8# Grant namespace-scoped edit access (can update deployments, services, etc.)
9aws eks associate-access-policy \
10  --cluster-name my-cluster \
11  --principal-arn arn:aws:iam::123456789:role/github-actions-deploy \
12  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy \
13  --access-scope type=namespace,namespaces=production \
14  --region us-east-1

AmazonEKSEditPolicy maps to the built-in edit ClusterRole — full control within the namespace but no cluster-wide access and no RBAC modification. Correct scope for a CI deployer.

Legacy aws-auth ConfigMap

For older clusters not yet migrated to access entries:

bash
# Edit the aws-auth ConfigMap
kubectl edit configmap aws-auth -n kube-system

Add under mapRoles:

yaml
- rolearn: arn:aws:iam::123456789:role/github-actions-deploy
  username: github-actions
  groups:
    - github-deployers

Then create a RoleBinding scoped to the production namespace:

yaml
1apiVersion: rbac.authorization.k8s.io/v1
2kind: RoleBinding
3metadata:
4  name: github-deployers
5  namespace: production
6roleRef:
7  apiGroup: rbac.authorization.k8s.io
8  kind: ClusterRole
9  name: edit
10subjects:
11  - kind: Group
12    name: github-deployers
13    apiGroup: rbac.authorization.k8s.io

Step 4: The CI Workflow (Build and Push)

yaml
1# .github/workflows/ci.yml
2name: CI  Build and Push
3
4on:
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10permissions:
11  id-token: write   # Required for OIDC
12  contents: read
13
14env:
15  AWS_REGION: us-east-1
16  ECR_REGISTRY: 123456789.dkr.ecr.us-east-1.amazonaws.com
17  ECR_REPOSITORY: my-app
18
19jobs:
20  build:
21    runs-on: ubuntu-latest
22    outputs:
23      image-tag: ${{ steps.meta.outputs.version }}
24
25    steps:
26    - name: Checkout
27      uses: actions/checkout@v4
28
29    - name: Configure AWS credentials via OIDC
30      uses: aws-actions/configure-aws-credentials@v4
31      with:
32        role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
33        role-session-name: github-actions-${{ github.run_id }}
34        aws-region: ${{ env.AWS_REGION }}
35
36    - name: Login to ECR
37      uses: aws-actions/amazon-ecr-login@v2
38
39    - name: Set up QEMU
40      uses: docker/setup-qemu-action@v3
41
42    - name: Set up Docker Buildx
43      uses: docker/setup-buildx-action@v3
44
45    - name: Extract image metadata
46      id: meta
47      uses: docker/metadata-action@v5
48      with:
49        images: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}
50        tags: |
51          type=sha,prefix=,format=short
52          type=ref,event=branch
53          type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
54
55    - name: Build and push
56      uses: docker/build-push-action@v5
57      with:
58        context: .
59        platforms: linux/amd64,linux/arm64
60        push: ${{ github.event_name != 'pull_request' }}
61        tags: ${{ steps.meta.outputs.tags }}
62        labels: ${{ steps.meta.outputs.labels }}
63        cache-from: type=gha
64        cache-to: type=gha,mode=max

Key decisions here:

${{ github.sha }} vs metadata action: I use docker/metadata-action instead of manually constructing ${{ github.sha }} because it handles the full tag list consistently — short SHA for immutable tracking, branch tag for convenience, latest only on main. The short SHA (type=sha,prefix=,format=short) gives you a 7-char tag like a1b2c3d that's easy to reference.

Multi-arch on every push: Building linux/amd64,linux/arm64 on every push is worth the extra 2-3 minutes if you're migrating to Graviton. If build time matters more than ARM readiness, drop to linux/amd64 only.

push: ${{ github.event_name != 'pull_request' }}: Builds run on PRs (for validation) but only push on merged commits.

cache-from: type=gha: GitHub Actions cache for Docker layer caching. Cuts build time by 60-80% on unchanged layers. The mode=max exports all intermediate layers, not just the final image.


Step 5: The CD Workflow (Deploy to EKS)

yaml
1# .github/workflows/deploy.yml
2name: CD  Deploy to EKS
3
4on:
5  workflow_run:
6    workflows: ["CI — Build and Push"]
7    types: [completed]
8    branches: [main]
9
10permissions:
11  id-token: write
12  contents: read
13
14concurrency:
15  group: deploy-production
16  cancel-in-progress: false
17
18jobs:
19  deploy:
20    runs-on: ubuntu-latest
21    if: ${{ github.event.workflow_run.conclusion == 'success' }}
22    environment: production
23
24    steps:
25    - name: Checkout
26      uses: actions/checkout@v4
27
28    - name: Configure AWS credentials via OIDC
29      uses: aws-actions/configure-aws-credentials@v4
30      with:
31        role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
32        role-session-name: eks-deploy-${{ github.run_id }}
33        aws-region: us-east-1
34
35    - name: Update kubeconfig
36      run: |
37        aws eks update-kubeconfig \
38          --name my-cluster \
39          --region us-east-1
40
41    - name: Get image tag from CI run
42      id: image-tag
43      run: |
44        # Extract short SHA from the triggering workflow's head commit
45        echo "tag=$(echo ${{ github.event.workflow_run.head_sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
46
47    - name: Deploy with Helm
48      run: |
49        helm upgrade --install my-app ./charts/my-app \
50          --namespace production \
51          --create-namespace \
52          --values ./charts/my-app/values-production.yaml \
53          --set image.tag=${{ steps.image-tag.outputs.tag }} \
54          --atomic \
55          --wait \
56          --timeout 5m \
57          --history-max 10
58
59    - name: Verify rollout
60      run: |
61        kubectl rollout status deployment/my-app -n production --timeout=120s

The concurrency block with cancel-in-progress: false is important: if two deploys are queued, the second one waits for the first to finish rather than cancelling it. You don't want to cancel an in-flight deployment — let it complete and then deploy the newer version on top.

environment: production integrates with GitHub's Environment protection rules — you can require manual approval before the deployment job runs, restrict which branches can deploy, and get a deployment history in the GitHub UI.


Reusable Workflow Pattern

If you have multiple services with the same deployment pattern, extract the deploy job into a reusable workflow:

yaml
1# .github/workflows/deploy-service.yml
2name: Deploy Service (Reusable)
3
4on:
5  workflow_call:
6    inputs:
7      service-name:
8        required: true
9        type: string
10      image-tag:
11        required: true
12        type: string
13      namespace:
14        required: true
15        type: string
16      environment:
17        required: true
18        type: string
19    secrets:
20      aws-role-arn:
21        required: true
22
23permissions:
24  id-token: write
25  contents: read
26
27jobs:
28  deploy:
29    runs-on: ubuntu-latest
30    environment: ${{ inputs.environment }}
31    steps:
32    - name: Checkout
33      uses: actions/checkout@v4
34
35    - name: Configure AWS credentials
36      uses: aws-actions/configure-aws-credentials@v4
37      with:
38        role-to-assume: ${{ secrets.aws-role-arn }}
39        aws-region: us-east-1
40
41    - name: Update kubeconfig
42      run: aws eks update-kubeconfig --name my-cluster --region us-east-1
43
44    - name: Helm deploy
45      run: |
46        helm upgrade --install ${{ inputs.service-name }} \
47          ./charts/${{ inputs.service-name }} \
48          --namespace ${{ inputs.namespace }} \
49          --set image.tag=${{ inputs.image-tag }} \
50          --atomic --wait --timeout 5m

Call it from any service's workflow:

yaml
1# Calling workflow must grant id-token: write — reusable workflow permissions
2# are constrained by the calling workflow, not inherited from the callee
3permissions:
4  id-token: write
5  contents: read
6
7jobs:
8  deploy:
9    uses: your-org/platform/.github/workflows/deploy-service.yml@main
10    with:
11      service-name: payment-service
12      image-tag: ${{ needs.build.outputs.image-tag }}
13      namespace: production
14      environment: production
15    secrets:
16      aws-role-arn: ${{ secrets.DEPLOY_ROLE_ARN }}

This is the pattern that eliminates copy-paste CI/CD across dozens of repos. The reusable workflow lives in a central platform repo; service repos just call it.


Staging → Production Promotion

For a staging-first promotion pattern:

yaml
1jobs:
2  deploy-staging:
3    uses: your-org/platform/.github/workflows/deploy-service.yml@main
4    with:
5      service-name: my-app
6      image-tag: ${{ needs.build.outputs.image-tag }}
7      namespace: staging
8      environment: staging
9
10  deploy-production:
11    needs: [deploy-staging]
12    uses: your-org/platform/.github/workflows/deploy-service.yml@main
13    with:
14      service-name: my-app
15      image-tag: ${{ needs.build.outputs.image-tag }}
16      namespace: production
17      environment: production  # requires manual approval via environment protection rule
18    secrets:
19      aws-role-arn: ${{ secrets.PROD_DEPLOY_ROLE_ARN }}

Staging deploys automatically. Production requires manual approval because environment: production has a required reviewer configured in GitHub settings. The approval gate runs at the job level — the workflow pauses until a reviewer clicks "Approve and deploy."


What This Gets You

ConcernThis Setup
Credential securityOIDC — no stored secrets, auto-expiring
Image provenanceSHA-pinned tags, immutable ECR images
Deployment safety--atomic auto-rollback on failure
Multi-env promotionStaging auto, production gated on approval
Concurrency safetyNo concurrent production deploys
Multi-service scaleReusable workflow, one definition for all services

If you need private network access, larger compute, GPU nodes, or want to run runners on your own EKS cluster instead of GitHub-hosted machines, see Actions Runner Controller on Kubernetes — the patterns in this post apply to both hosted and self-hosted runners.

The missing piece for full GitOps is decoupling the deploy trigger from the CI pipeline — instead of GitHub Actions running helm upgrade directly, it updates the image tag in a Git-tracked values file, and ArgoCD or Flux handles the actual apply (see ArgoCD ApplicationSet progressive syncs and FluxCD in production). For teams that want CI/CD in a single system without a separate GitOps operator, this pipeline is the right tradeoff.


Try the toolkit: Generate Kubernetes Deployment, Service, and HPA YAML for the applications you're deploying via this pipeline with the Kubernetes Deployment Generator.


Migrating from static AWS keys to OIDC across a large GitHub org? Talk to us at Coding Protocols. We audit IAM role configurations and build standardized CI/CD pipelines for platform teams managing multiple services on EKS.

Related Topics

GitHub Actions
EKS
ECR
CI/CD
OIDC
DevOps
Platform Engineering
Helm

Found this useful? Share it.

Practice this

Read Next