After deploying my personal website to AWS, I realized the need to automate the deployment process to streamline the delivery of new features. For that, you can’t go wrong with a CI/CD pipeline powered by your Git provider — in this case, GitHub and GitHub Actions.
In this article, I’ll walk through the architecture and CI/CD pipeline I use to deploy a React application to S3 + CloudFront, authenticated via GitHub Actions OIDC (no long-lived AWS credentials).
This setup is suitable for real-world projects and follows AWS and GitHub best practices.
Architecture Overview
Before diving into CI/CD, let’s clarify the infrastructure that was already in place.
Initial AWS Setup (Pre-requisites)
The following resources were already created before the pipeline was written.
1 - S3 bucket
- Static website hosting enabled
- Public access configured appropriately
- Hosts the built React assets (index.html, /assets/*, etc.)
2 - CloudFront distribution
- Origin pointing to the S3 website endpoint
- Default root object: index.html
- Caching enabled
- Custom domain configured
3 - Name.com
- Custom domain pointing to the CloudFront distribution
Once this setup is complete, the website can already be accessed via the custom domain.
The CI/CD pipeline’s job is to automate updates safely and consistently.
AWS: Configuring OIDC Authentication
1 - Create an OIDC Identity Provider
In AWS IAM:
- Provider URL:
https://token.actions.githubusercontent.com
- Audience:
sts.amazonaws.com
This allows AWS to trust GitHub as an identity provider.
2 - Create an IAM Role for GitHub Action
This role will be assumed by the GitHub Actions runner.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<ACCOUNT_ID>: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:<ORG>/<REPO>:*"
}
}
}
]
}
3 - Attach Required IAM Permissions
The role needs permissions to:
- Sync files to S3
- Create CloudFront invalidations
Example policy (simplified):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::<BUCKET_NAME>",
"arn:aws:s3:::<BUCKET_NAME>/*"
]
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "*"
}
]
}
GitHub Actions Pipeline Overview
The pipeline is split into three jobs:
- install – install dependencies
- build – build the React app and upload artifacts
- deploy – authenticate to AWS, deploy to S3, invalidate CloudFront
This separation improves clarity, caching, and debuggability.
Job 1: Installing Dependencies
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: 'npm'
- run: npm ci
What this job does
- Checks out the repository
- Sets up Node.js 22
- Uses npm ci for deterministic installs
- Enables dependency caching for faster builds
This job ensures the dependency tree is valid before moving forward.
Job 2: Building the React Application
build:
runs-on: ubuntu-latest
needs: install
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: dist
path: ./dist
What this job does
- Rebuilds the application in a clean environment
- Produces a static dist/ folder
- Uploads the build output as a pipeline artifact
Artifacts allow the deploy job to be fully decoupled from the build process.
Job 3: Deploying to AWS (S3 + CloudFront)
deploy:
needs: build
runs-on: ubuntu-latest
environment: aws
permissions:
id-token: write
contents: read
steps:
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ARN }}
aws-region: ${{ vars.AWS_REGION }}
role-session-name: ${{ vars.ROLE_SESSION_NAME}}
- name: Deploy to S3
run: |
aws s3 sync dist s3://${{ vars.S3_BUCKET_NAME }} \
--delete \
--exact-timestamps
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
What this job does
- Downloads the build artifact
- Authenticates to AWS
- Uploads the static files to the bucket
- Invalidate CloudFront's cache
Invalidating /* ensures CloudFront fetches the new version immediately.
Final Result
After every push to main:
- The app is built
- Assets are uploaded to S3
- CloudFront cache is invalidated
- The custom domain serves the latest version reliably
All without storing a single AWS secret in GitHub.
Top comments (0)