GitHub Actions Deploy to AWS: OIDC, IAM Roles, and Real Workflows

Bits Lovers
Written by Bits Lovers on
GitHub Actions Deploy to AWS: OIDC, IAM Roles, and Real Workflows

In 2021, GitHub released OIDC support for Actions — and quietly made static AWS access keys in CI/CD pipelines obsolete. The old approach required storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub secrets, rotating them manually, and hoping nobody accidentally logged them. The new approach is a trust relationship: your GitHub Actions workflow requests temporary credentials from AWS STS using a signed JWT from GitHub, gets short-lived credentials good for the job duration, and never touches a long-term key.

Most teams are still using the old approach. This guide covers the right way to deploy to AWS from GitHub Actions in 2026. If you’re evaluating CI/CD platforms more broadly, the GitHub Actions vs GitLab CI comparison covers how the two differ on runners, pricing, security scanning, and self-hosting.

OIDC: How It Actually Works

GitHub runs an OpenID Connect identity provider at token.actions.githubusercontent.com. When a workflow job starts, GitHub issues a signed JWT token containing claims about the workflow: the repository, the branch, the environment, the triggering event. Your workflow can exchange that token for AWS credentials by calling STS AssumeRoleWithWebIdentity.

For this to work, AWS needs to trust GitHub’s OIDC provider, and the IAM role’s trust policy needs to specify which GitHub repository and workflow context can assume it. The credentials that come back are temporary — default session duration is one hour — and scoped to exactly what the role’s permission policies allow.

Setting up the OIDC provider in AWS (one-time per account):

# Get GitHub's OIDC thumbprint
THUMBPRINT=$(openssl s_client -connect token.actions.githubusercontent.com:443 \
  -showcerts </dev/null 2>/dev/null | \
  openssl x509 -fingerprint -sha1 -noout | \
  sed 's/.*=//; s/://g' | tr '[:upper:]' '[:lower:]')

# Create the OIDC provider
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list $THUMBPRINT

Or with Terraform:

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

That thumbprint hash comes from GitHub’s TLS leaf certificate. The openssl command above extracts it automatically. It’s stable but worth verifying if you’re reading this a year from now — certificate rotations do happen.

The IAM Role Trust Policy

The trust policy is where you specify which GitHub repos and branches can assume the role. Be precise here — a trust policy that accepts any repository in your organization is a significant security risk.

{
  "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:my-org/my-repo:*"
        }
      }
    }
  ]
}

The sub condition claim controls which repository and context can assume the role. repo:my-org/my-repo:* allows any branch or event in that repo. Tighten it further with specific branch restrictions:

"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"

This limits the role to workflows running on the main branch. A workflow on a feature branch gets denied — which is exactly what you want for production deployments. The trust policy mechanics are covered in detail in the AWS IAM roles and policies guide, including how the condition keys work and what happens when cross-account trust is involved.

Deploying a Lambda Function

A typical Lambda deployment workflow: test on push to any branch, deploy on merge to main.

name: Deploy Lambda

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

permissions:
  id-token: write   # Required for OIDC
  contents: read

env:
  AWS_REGION: us-east-1
  FUNCTION_NAME: my-lambda-function

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

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install and test
        run: |
          npm ci
          npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    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::123456789012:role/github-deploy-lambda
          aws-region: $

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Build
        run: npm ci && npm run build

      - name: Deploy to Lambda
        run: |
          zip -r function.zip dist/ node_modules/
          aws lambda update-function-code \
            --function-name $ \
            --zip-file fileb://function.zip

      - name: Wait for update
        run: |
          aws lambda wait function-updated \
            --function-name $

      - name: Publish version
        run: |
          aws lambda publish-version \
            --function-name $

Two things worth noting. The permissions: id-token: write block at the workflow level is required — without it, GitHub doesn’t issue the OIDC token and configure-aws-credentials fails silently. The environment: production block on the deploy job enables GitHub Environments, which can require manual approval before the job runs.

Deploying a Static Site to S3

Static sites are the straightforward case — four IAM actions total. s3:PutObject and s3:DeleteObject handle the sync, s3:ListBucket lets the CLI see what’s already there before deciding what to upload or delete, and cloudfront:CreateInvalidation busts the CDN cache after the sync finishes. Scope everything to the specific bucket ARN, not a wildcard.

name: Deploy Frontend

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    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::123456789012:role/github-deploy-frontend
          aws-region: us-east-1

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Build
        run: npm ci && npm run build

      - name: Sync to S3
        run: |
          aws s3 sync dist/ s3://my-bucket/ \
            --delete \
            --cache-control "max-age=31536000,immutable" \
            --exclude "index.html"

          # index.html with shorter cache
          aws s3 cp dist/index.html s3://my-bucket/index.html \
            --cache-control "no-cache"

      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id $ \
            --paths "/*"

The split sync and cp approach sets different cache headers for static assets versus index.html. Hashed assets (bundles with content hashes in filenames) can be cached aggressively; index.html should never be cached by CDN so users always get the current version pointing at the right asset hashes.

Deploying to ECS

ECS deployments need a bit more: build and push to ECR, then update the service.

name: Deploy to ECS

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: my-app
  ECS_SERVICE: my-service
  ECS_CLUSTER: my-cluster
  CONTAINER_NAME: app

jobs:
  deploy:
    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::123456789012:role/github-deploy-ecs
          aws-region: $

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

      - name: Build, tag, and push image
        id: build-image
        env:
          ECR_REGISTRY: $
          IMAGE_TAG: $
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Download task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition my-task \
            --query taskDefinition > task-definition.json

      - name: Update task definition with new image
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: $
          image: $

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: $
          service: $
          cluster: $
          wait-for-service-stability: true

The wait-for-service-stability: true flag makes the action wait until ECS confirms the new task definition is running before the job completes. Without it, the deploy job finishes immediately and you won’t know about a failed deployment until you check ECS or your CloudWatch alarms fire.

Environments and Approval Gates

GitHub Environments let you gate deployments behind manual approval, branch protection, and wait timers. Configure them at Settings > Environments in your repository.

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    # No approval required — deploys automatically

  deploy-production:
    runs-on: ubuntu-latest
    environment: production
    needs: deploy-staging
    # Requires manual approval from configured reviewers

The environment: production job pauses until a configured reviewer approves from the GitHub Actions UI. Reviewers get a notification, see the commit diff, and click Approve. Only then does the deploy job run. For compliance-sensitive deployments this is a lightweight audit trail without requiring a separate approval system.

Environment-specific secrets and variables are scoped to the environment. Production database passwords, production IAM role ARNs, production CloudFront distribution IDs — none of these live at the repository level where any branch workflow could access them.

Caching Dependencies

Actions billing is per compute minute. Slow workflows add up. Caching node_modules, pip packages, or Maven artifacts can cut 2-5 minutes off build times.

- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: $-node-$
    restore-keys: |
      $-node-

hashFiles hashes the lock file to generate the cache key. Change package-lock.json and npm downloads fresh dependencies on the next run; leave it unchanged and the cache restores in a few seconds before npm ci finishes in milliseconds. The same pattern works for Python — cache ~/.cache/pip keyed on requirements.txt — and Maven — cache ~/.m2 keyed on pom.xml.

IAM Role Scoping

Each deployment target should have its own IAM role with the minimum required permissions. Don’t create one github-deploy role with broad permissions that all workflows share.

Lambda is clean: UpdateFunctionCode and PublishVersion scoped to the function ARN, plus GetFunction to poll status. Three actions, one function.

ECS is messier. GetAuthorizationToken can’t be scoped to a resource — it’s always account-level. The ECR push actions (BatchCheckLayerAvailability, PutImage, InitiateLayerUpload, UploadLayerPart, CompleteLayerUpload) scope to the repository. RegisterTaskDefinition is account-level too. UpdateService and the Describe calls scope to the cluster. Then there’s iam:PassRole pointed at the task execution role — that’s the one that bites people on their first ECS deploy. Without it, the UpdateService call succeeds, the new task never launches, and you’re reading ECS events wondering what went wrong.

The iam:PassRole permission trips people up. ECS needs to assume the task execution role when launching tasks, and your deploy role needs iam:PassRole to pass that role to ECS. Without it, ecs:UpdateService succeeds but the task fails to launch. Scoping iam:PassRole tightly to the task execution role ARN — not iam:PassRole on * — keeps the blast radius small.

For secrets management in your deployed services, the AWS Secrets Manager guide covers how Lambda and ECS tasks access secrets at runtime without putting them in environment variables.

Debugging OIDC Failures

OIDC failures produce cryptic errors. Common ones:

Error: Not authorized to perform sts:AssumeRoleWithWebIdentity — the trust policy condition doesn’t match the workflow context. Check whether the sub claim matches the branch or event type. A trust policy restricting to refs/heads/main will deny a PR workflow running on refs/pull/123/merge.

Error: The security token included in the request is expired — the job ran longer than the role’s MaxSessionDuration. Default is one hour. Set it to 4 hours for long-running deploy jobs.

The “Invalid token” error almost always means the id-token: write permission block is missing. GitHub won’t issue an OIDC token without it, and configure-aws-credentials fails silently in a way that reads like a token problem. Add it at workflow level or on each individual job — whichever scope the job runs in.

“Could not fetch OIDC token” is different. That’s a forked repository problem. Fork PRs don’t receive OIDC tokens from GitHub — it’s intentional, a security boundary preventing fork contributors from accessing production credentials. Your deploy job should never run on pull_request events from forks; restrict it to push events on protected branches only.

Switching from static keys to OIDC removes a whole category of credential leakage risk. The initial setup takes 20 minutes. The trust you get — no long-term credentials in GitHub, automatic expiry, per-repo and per-branch scoping — is worth every minute of it.

Bits Lovers

Bits Lovers

Professional writer and blogger. Focus on Cloud Computing.

Comments

comments powered by Disqus