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
| Phase | Tool | Key Action |
|---|---|---|
| Build | GitHub Actions + Node | npm ci && npm run build |
| Upload | AWS CLI | aws s3 sync ./build s3://your-bucket --delete |
| Invalidate | CloudFront API | aws cloudfront create-invalidation |
| Auth | OIDC (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.
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"]
- Push triggers workflow: GitHub detects a push to the target branch and queues a runner.
- 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.
- Build step: Node installs dependencies from the lockfile (
npm ci) and produces abuild/directory. - S3 sync: The AWS CLI uploads changed files and removes deleted ones. The
--deleteflag is critical — without it, old files accumulate and old hashed filenames remain accessible. - 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 2AWS_REGION— e.g.,us-east-1S3_BUCKET— your bucket nameCLOUDFRONT_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.
- Push to main: Triggers the GitHub Actions workflow.
- OIDC auth: Runner exchanges a GitHub JWT for temporary AWS credentials via STS.
- Build:
npm ciandnpm run buildproduce thebuild/directory. - S3 sync: Hashed assets synced with immutable cache headers;
index.htmluploaded separately with no-cache headers. - Invalidation: CloudFront edge cache cleared so the next request fetches fresh objects from S3.
- 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_requesttrigger that runsnpm run buildwithout 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
| Term | Definition |
|---|---|
| OIDC | OpenID Connect — a protocol that allows GitHub Actions runners to authenticate to AWS without static credentials, using short-lived signed tokens. |
| CloudFront Invalidation | An 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-Control | An 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 sync | An 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
- 📄 How to Make CloudFront Serve Your S3 Website with a Custom Domain and HTTPS
- 📄 CloudFront + S3 Static Website 403 Errors: OAC vs Public Bucket Explained
- 📄 CloudFront Cache Invalidation: Force-Refresh Stale Edge Content After S3 Updates
- 📄 How to Host a Static Website on S3: Step-by-Step Guide
- 📄 S3 Public Access Denied: Why Your Public Object URL Still Returns 403
Comments
Post a Comment