How to Deploy a React App to S3 and CloudFront Using GitHub Actions

Every time you push to main, you want your React app built and live — not a manual aws s3 sync from your laptop. Setting up a GitHub Actions workflow that runs npm run build and deploys to S3 with CloudFront invalidation is straightforward once you understand the IAM surface and the ordering of operations. Get either wrong and you end up with stale cache or a pipeline that works on your machine but fails in CI.

TL;DR: React App Deployment to S3 and CloudFront

PhaseToolKey Action
BuildGitHub Actions + Nodenpm ci && npm run build
UploadAWS CLIaws s3 sync ./build s3://your-bucket --delete
InvalidateCloudFront APIaws cloudfront create-invalidation
AuthOIDC (recommended)No long-lived AWS credentials in GitHub Secrets

How the Deployment Pipeline Works

Before writing a single line of YAML, it helps to understand what actually happens between a git push and a user seeing updated content in their browser. There are four distinct layers: the GitHub Actions runner, AWS IAM authentication, S3 object storage, and the CloudFront edge cache. Each layer has its own failure mode.

graph LR A["git push
to main"] --> B["GitHub Actions
Runner"] B --> C["OIDC Token
Request"] C --> D["AWS STS
AssumeRoleWithWebIdentity"] D --> E["Temporary
Credentials"] E --> F["npm ci
npm run build"] F --> G["aws s3 sync
./build to S3"] G --> H["CloudFront
Invalidation"] H --> I["User sees
updated app"]
  1. Push triggers workflow: GitHub detects a push to the target branch and queues a runner.
  2. OIDC token exchange: The runner requests a short-lived token from GitHub's OIDC provider. AWS STS validates it and returns temporary credentials — no static keys stored anywhere.
  3. Build step: Node installs dependencies from the lockfile (npm ci) and produces a build/ directory.
  4. S3 sync: The AWS CLI uploads changed files and removes deleted ones. The --delete flag is critical — without it, old files accumulate and old hashed filenames remain accessible.
  5. CloudFront invalidation: Invalidating /* forces edge nodes to fetch fresh objects. Without this step, users may see the previous version for the remainder of the TTL.

Prerequisites

  • An S3 bucket configured for static website hosting (or as a CloudFront origin — these are different configurations)
  • A CloudFront distribution pointing at the S3 bucket
  • An AWS IAM identity provider configured for GitHub Actions OIDC
  • An IAM role with the minimum permissions listed below

Step 1: Configure the GitHub OIDC Identity Provider in AWS

Using OIDC eliminates long-lived AWS access keys from your GitHub Secrets entirely. The runner proves its identity to AWS STS using a signed JWT from GitHub — AWS validates the token against the registered identity provider and returns temporary credentials scoped to the role you specify.

Create the OIDC provider once per AWS account:

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

Verify it exists:

aws iam list-open-id-connect-providers
Think of the OIDC provider registration as telling AWS: 'I trust tokens signed by GitHub's identity service.' The thumbprint pins the TLS certificate of GitHub's OIDC endpoint so AWS can verify the token source hasn't changed.

Step 2: Create the IAM Role for GitHub Actions

The role needs a trust policy that restricts which GitHub repository and branch can assume it. Without the sub condition, any GitHub Actions workflow — in any repository — could assume your role if they know the role ARN.

🔽 Click to expand: trust-policy.json
{
  "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",
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}
aws iam create-role \
  --role-name GitHubActions-ReactDeploy \
  --assume-role-policy-document file://trust-policy.json

Now attach a permissions policy. The role needs S3 object operations on the target bucket and CloudFront invalidation on the target distribution. Read and List operations on S3 require Resource: "*" for some actions — verify against the Service Authorization Reference before tightening further.

🔽 Click to expand: deploy-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-bucket-name",
        "arn:aws:s3:::your-bucket-name/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "cloudfront:CreateInvalidation",
      "Resource": "arn:aws:cloudfront::123456789012:distribution/YOUR_DISTRIBUTION_ID"
    }
  ]
}
aws iam put-role-policy \
  --role-name GitHubActions-ReactDeploy \
  --policy-name ReactDeployPolicy \
  --policy-document file://deploy-policy.json

Confirm the role ARN — you'll need it in the workflow:

aws iam get-role \
  --role-name GitHubActions-ReactDeploy \
  --query 'Role.Arn' \
  --output text

Step 3: Store Configuration in GitHub Repository Variables

Add these as repository secrets or variables in your GitHub repository settings (Settings → Secrets and variables → Actions):

  • AWS_ROLE_ARN — the IAM role ARN from Step 2
  • AWS_REGION — e.g., us-east-1
  • S3_BUCKET — your bucket name
  • CLOUDFRONT_DISTRIBUTION_ID — your distribution ID

None of these are AWS credentials. The OIDC flow handles authentication dynamically — there are no access keys to rotate or leak.

Step 4: Write the GitHub Actions Workflow

The workflow file lives at .github/workflows/deploy.yml in your repository. The id-token: write permission is required for the OIDC token request — without it, the aws-actions/configure-aws-credentials action cannot obtain credentials and the job fails with a permissions error that looks unrelated to AWS.

🔽 Click to expand: .github/workflows/deploy.yml
name: Deploy React App to S3 and CloudFront

on:
  push:
    branches:
      - main

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source
        uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Build React app
        run: npm run build

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Sync build output to S3
        run: |
          aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} \
            --delete \
            --cache-control 'public,max-age=31536000,immutable' \
            --exclude 'index.html'
          aws s3 cp ./build/index.html s3://${{ secrets.S3_BUCKET }}/index.html \
            --cache-control 'no-cache,no-store,must-revalidate'

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths '/*'

The S3 sync is split into two commands intentionally. Hashed static assets (main.abc123.js) are safe to cache aggressively — they never change at a given filename. But index.html must never be cached, because it's the entry point that references those hashed filenames. Cache it and users get a stale shell pointing at assets that may no longer exist.

Step 5: Verify the Deployment

After the workflow completes, confirm the objects landed correctly and the invalidation was created:

aws s3 ls s3://your-bucket-name --recursive | head -20
aws cloudfront list-invalidations \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --query 'InvalidationList.Items[0]' \
  --output json

Check that the invalidation status shows Completed before concluding the deployment is live. An invalidation in InProgress state means some edge nodes may still be serving the previous version.

aws cloudfront get-invalidation \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --id YOUR_INVALIDATION_ID \
  --query 'Invalidation.Status' \
  --output text

Common Failure: The Cache-Control Misdiagnosis

Here's a failure pattern that wastes an afternoon: the workflow succeeds, S3 has the new files, but users — including you — still see the old version. The instinct is to blame CloudFront propagation delay. So you wait. Still stale. You manually invalidate again. Still stale.

The actual cause: the browser cached index.html with a long TTL from a previous deployment where --cache-control wasn't set on that file specifically. CloudFront served fresh content, but the browser never asked for it. The fix is the split sync pattern above — index.html with no-cache, everything else with immutable long TTL. Once you set this correctly, the browser always fetches a fresh index.html, which then pulls the correct hashed asset filenames.

The symptom and the fix are both in the browser layer, not CloudFront — which is why checking CloudFront invalidation status sends you in the wrong direction entirely.

Depth: The --delete Flag and SPA Routing

Using --delete on aws s3 sync removes objects from S3 that no longer exist locally. This is correct behavior for cleaning up old hashed bundles. However, if you've manually uploaded any files to the bucket — a robots.txt, a .well-known/ directory, or a custom error page — those will be deleted on the next deployment. The sync treats S3 as a mirror of your local build/ directory, not as a shared storage location.

For single-page applications, CloudFront needs to be configured to return index.html for 403 and 404 responses from S3 — this is a CloudFront error page configuration, not an S3 setting. If you're using S3 static website hosting as the origin (rather than the REST endpoint with OAC), the bucket itself handles routing differently. These are distinct origin types with different behaviors, and mixing up which one you've configured is a common source of broken client-side routing in production.

sequenceDiagram participant Dev as Developer participant GH as GitHub Actions participant STS as AWS STS participant S3 as S3 Bucket participant CF as CloudFront participant Browser as User Browser Dev->>GH: git push to main GH->>STS: AssumeRoleWithWebIdentity (OIDC JWT) STS-->>GH: Temporary credentials GH->>GH: npm ci && npm run build GH->>S3: sync hashed assets (immutable cache) GH->>S3: cp index.html (no-cache) GH->>CF: create-invalidation /* CF-->>GH: Invalidation ID Browser->>CF: GET index.html CF->>S3: Fetch fresh index.html S3-->>CF: index.html (new version) CF-->>Browser: index.html with new asset refs Browser->>CF: GET main.abc123.js CF-->>Browser: Cached or fresh asset
  1. Push to main: Triggers the GitHub Actions workflow.
  2. OIDC auth: Runner exchanges a GitHub JWT for temporary AWS credentials via STS.
  3. Build: npm ci and npm run build produce the build/ directory.
  4. S3 sync: Hashed assets synced with immutable cache headers; index.html uploaded separately with no-cache headers.
  5. Invalidation: CloudFront edge cache cleared so the next request fetches fresh objects from S3.
  6. User request: Browser fetches fresh index.html, which references the new hashed asset filenames.

Wrap-Up and Next Steps for Your React Deployment Pipeline

This pipeline covers the full deployment path for a React app to S3 and CloudFront using GitHub Actions — OIDC-based authentication, correct cache-control headers, and CloudFront invalidation in the right order. The most operationally significant detail is the split sync for index.html versus static assets; get that wrong and you'll chase ghost deployments.

From here, consider:

  • Adding a pull_request trigger that runs npm run build without deploying — catches build failures before merge
  • Scoping the OIDC trust policy to specific environments using GitHub's environment protection rules
  • Using S3 bucket versioning if you need rollback capability without redeploying
  • Reviewing the CloudFront invalidation documentation for cost implications of frequent /* invalidations

Glossary

TermDefinition
OIDCOpenID Connect — a protocol that allows GitHub Actions runners to authenticate to AWS without static credentials, using short-lived signed tokens.
CloudFront InvalidationAn API call that instructs CloudFront edge nodes to discard cached copies of specified paths and fetch fresh objects from the origin on the next request.
OAC (Origin Access Control)A CloudFront mechanism that restricts S3 bucket access to only the CloudFront distribution, preventing direct public S3 access.
Cache-ControlAn HTTP header that instructs browsers and CDN edge nodes how long to cache a response. Setting this incorrectly on index.html is the most common cause of stale deployments.
aws s3 syncAn AWS CLI command that mirrors a local directory to an S3 prefix, uploading changed files and optionally deleting objects that no longer exist locally.

Related Posts

Comments

Popular posts from this blog

AWS SNS Email Alerts Not Arriving? The Subscription Confirmation Trap Explained

S3 Public Access Denied: Why Your Public Object URL Still Returns 403

EC2 No Internet Access in Custom VPC: Fix Internet Gateway and Route Table