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.

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-reporef:refs/heads/mainworkflow:deployenvironment: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
# 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 6938fd4d98bab03faadb97b34396831e3780aea1The --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
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:
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.jsonThe 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:
"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
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.
EKS Access Entries (Recommended)
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:
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-11# 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-1AmazonEKSEditPolicy 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:
# Edit the aws-auth ConfigMap
kubectl edit configmap aws-auth -n kube-systemAdd under mapRoles:
- rolearn: arn:aws:iam::123456789:role/github-actions-deploy
username: github-actions
groups:
- github-deployersThen create a RoleBinding scoped to the production namespace:
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.ioStep 4: The CI Workflow (Build and Push)
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=maxKey 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)
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=120sThe 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:
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 5mCall it from any service's workflow:
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:
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
| Concern | This Setup |
|---|---|
| Credential security | OIDC — no stored secrets, auto-expiring |
| Image provenance | SHA-pinned tags, immutable ECR images |
| Deployment safety | --atomic auto-rollback on failure |
| Multi-env promotion | Staging auto, production gated on approval |
| Concurrency safety | No concurrent production deploys |
| Multi-service scale | Reusable 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
Found this useful? Share it.


