Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 066 / 100

CI/CD to AWS with GitHub Actions: lessons from production

Deploy without long-lived access keys.

Expona Admin7 min read

The Problem with Long-Lived Credentials

Most teams I've worked with start their AWS CI/CD journey the same way: generate an IAM user with programmatic access, stash the access key in GitHub Secrets, and call it done. It works, sure, but it's a ticking security bomb. That key never rotates. If it leaks—and it will eventually—an attacker has permanent access to your AWS account. CI/CD to AWS with GitHub Actions using OpenID Connect (OIDC) federation solves this by letting GitHub prove its identity to AWS without storing any long-lived secrets. You get temporary credentials scoped to a single workflow run. Once the workflow ends, those credentials evaporate.

How the Pipeline Flows: Step by Step

Let me walk you through a real deployment pipeline I built for an e-commerce platform. The flow is straightforward: GitHub's OIDC provider sends a signed token to AWS, AWS verifies it and grants temporary credentials, your workflow builds and tests, pushes an image to ECR, updates your infrastructure, then runs smoke tests to confirm everything's live.

Here's what each stage does:

  • Assume role via OIDC: GitHub proves "this request really came from our repo" without storing secrets.
  • Build and test: Run your unit tests, linting, security scans—fail fast if there are issues.
  • Push image to ECR: Once tests pass, build a Docker image and push it to AWS Elastic Container Registry.
  • Update ECS or deploy stack: Trigger a new ECS task definition, CloudFormation stack update, or SAM deployment.
  • Run smoke tests: Hit your live endpoints to confirm the deployment actually worked.

This structure means you catch errors early, automate the tedious bits, and keep your AWS account locked down. No developer ever handles an AWS key manually.

Setting Up OIDC Federation Between GitHub and AWS

OIDC federation is the technical foundation here. Instead of IAM credentials, GitHub's OIDC provider (an identity provider run by GitHub) issues a cryptographically signed token. AWS verifies that token's signature and the claims inside it (like which repo and branch the request came from), then grants temporary credentials if everything checks out.

First, create an IAM identity provider in your AWS account that trusts GitHub's OIDC provider. You only do this once per account:

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

That thumbprint is GitHub's public certificate fingerprint. AWS uses it to verify that tokens really came from GitHub and haven't been tampered with.

Next, create an IAM role that your GitHub Actions workflows will assume. This role trusts the GitHub OIDC provider and has permissions to deploy your application:

bash
aws iam create-role \
  --role-name GitHubActionsDeployRole \
  --assume-role-policy-document file://trust-policy.json

The trust policy looks like this (I'm using a JSON file for clarity):

bash
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:myorg/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Notice the condition: it restricts token acceptance to your specific repo and the main branch. That's critical scoping—we'll dig into this more below.

Scoping the Role: Repo and Branch Restrictions

One of the biggest mistakes I see is creating a deployment role that accepts tokens from any workflow in any GitHub org. That's as reckless as leaving your AWS key in a public repo. You must scope the OIDC trust condition to the specific repos and branches that should be able to assume the role.

In the trust policy, the token.actions.githubusercontent.com:sub claim tells you which GitHub repo and branch made the request. The format is repo:OWNER/REPO:ref:refs/heads/BRANCH or for pull requests, repo:OWNER/REPO:pull_request.

Here's a scoping strategy I use on production platforms:

  • For main/production: Allow only the main branch of your primary repo.
  • For staging: Allow the develop branch, optionally plus pull requests for testing.
  • For development/preview: Allow feature branches, but with fewer permissions or a time limit.

In the trust policy, you can use wildcards:

bash
"token.actions.githubusercontent.com:sub": [
  "repo:myorg/my-repo:ref:refs/heads/main",
  "repo:myorg/my-repo:ref:refs/heads/develop"
]

Then attach an inline policy to the role with the minimum permissions needed to deploy. For an ECS service, I'd give permissions to ecs:UpdateService, ecr:GetAuthorizationToken, ecr:BatchGetImage, and ecr:PutImage. Nothing more. This is the principle of least privilege.

A Real GitHub Actions Workflow

Now let's build the workflow. Here's one that deploys a Node.js service to ECS:

bash
name: Deploy to ECS

on:
  push:
    branches:
      - main

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Assume AWS role via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
          aws-region: us-east-1

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

      - name: Build and push image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: my-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

      - name: Update ECS service
        run: |
          aws ecs update-service \
            --cluster production \
            --service my-app \
            --force-new-deployment

      - name: Wait for deployment
        run: |
          aws ecs wait services-stable \
            --cluster production \
            --services my-app

      - name: Run smoke tests
        run: |
          curl -f https://api.myapp.com/health || exit 1

Key points in this workflow:

  • permissions: id-token: write allows GitHub to generate the OIDC token.
  • aws-actions/configure-aws-credentials exchanges the token for temporary AWS credentials using the OIDC provider.
  • The role ARN is hardcoded, scoped by GitHub Actions' condition checks on the repo and branch.
  • We tag images with the commit SHA for traceability.
  • The smoke test confirms the new service actually responds.

Environment Approvals for Production Safety

Even with OIDC and scoped roles, you still want a human to sign off before production deploys. GitHub Actions environments let you require approval before running a job.

In your workflow YAML, add the environment key to the job:

bash
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      # ... deployment steps ...

Then, in your GitHub repo settings, go to Environments, create a "production" environment, and add required reviewers. When the workflow reaches that job, GitHub pauses and notifies your reviewers. They can inspect the commit, check which image is about to be deployed, and either approve or reject.

I've prevented several incidents this way. A junior developer pushed code to main, the workflow ran, and the environment approval caught a missing database migration before it hit production. The deployment was blocked, the migration was added, and a new workflow was triggered. That friction saved us hours of incident response.

Common Mistakes

  • Overly broad OIDC conditions: Not scoping the sub claim to specific repos or branches means any GitHub org can assume your role if they know the role ARN.
  • Giving the deployment role too many permissions: If the role can delete RDS instances or modify IAM policies, a compromised workflow becomes a full account compromise. Attach only what's needed.
  • Forgetting to enable id-token in permissions: Without permissions: id-token: write, the workflow can't generate the OIDC token and the assume-role step fails silently.
  • Not running smoke tests: A deployment can succeed in AWS but fail in the real world if your load balancer isn't routing traffic correctly or your database connection pool is exhausted. Always verify the app actually works after deploy.
  • Storing secrets in environment variables: If your app needs database passwords or API keys, use AWS Secrets Manager or Parameter Store, not GitHub Secrets alongside OIDC. GitHub Secrets are still credentials.

Key Takeaways

  • OIDC federation eliminates long-lived AWS credentials from GitHub and your local machine—temporary credentials live for the workflow run only.
  • Trust your GitHub OIDC provider once in AWS IAM, then scope the trust condition to specific repos and branches using the sub claim.
  • Attach the deployment role with minimal permissions (ecs:UpdateService, ecr:PutImage, etc.), respecting least privilege.
  • Use GitHub Actions environments and required approvals to gate production deployments—a human review catches mistakes faster than any automated check.
  • Always run smoke tests after deployment to confirm the live service responds correctly before declaring success.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles