React CI/CD with GitHub Actions: Build, Test and Deploy (2026)
A solid CI/CD pipeline catches bugs before they reach users and automates every manual deployment step. This guide covers GitHub Actions workflows for React apps: parallel lint/type-check/test jobs, Docker build and push to a registry, preview deployments on pull requests, and production deployments to Vercel and AWS ECS.
Table of Contents
Basic CI Workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
PNPM_VERSION: '9'
jobs:
ci:
name: Lint, Type-check and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Unit tests
run: pnpm test --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Build
run: pnpm build
env:
VITE_API_URL: ${{ secrets.VITE_API_URL_STAGING }}
Parallel Jobs
# Run lint, typecheck, test and build in parallel — faster pipelines
name: CI
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm test --coverage
e2e:
runs-on: ubuntu-latest
needs: [lint, typecheck] # Only run E2E if static checks pass
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- run: pnpm build
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
deploy:
needs: [lint, typecheck, test, e2e]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: echo "All checks passed — deploying"
Docker Build and Push
# .github/workflows/docker.yml
name: Docker Build and Push
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions cache
cache-to: type=gha,mode=max
build-args: |
VITE_API_URL=${{ secrets.VITE_API_URL_PROD }}
Preview Deployments
# Deploy a preview for every PR — comment the URL back to the PR
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
VITE_API_URL: ${{ secrets.VITE_API_URL_STAGING }}
# Deploy to Cloudflare Pages preview
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-react-app
directory: dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '🚀 Preview deployed: ${{ steps.deploy.outputs.url }}'
})
Vercel Deployment
# .github/workflows/vercel.yml
name: Vercel Deployment
on:
push:
branches: [main]
pull_request:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: ${{ github.ref == 'refs/heads/main' && '--prod' || '' }}
github-comment: true # Comments preview URL on PRs
# Get IDs: vercel link --yes then cat .vercel/project.json
AWS ECS Deployment
# .github/workflows/aws-deploy.yml
name: Deploy to AWS ECS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC — no long-lived AWS keys in secrets
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-deploy
aws-region: us-east-1
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag and push image to ECR
id: build
env:
ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/my-react-app:$IMAGE_TAG .
docker push $ECR_REGISTRY/my-react-app:$IMAGE_TAG
echo "image=$ECR_REGISTRY/my-react-app:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Update ECS task definition with new image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: .aws/task-definition.json
container-name: web
image: ${{ steps.build.outputs.image }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: my-react-app-service
cluster: production
wait-for-service-stability: true
Secrets and Environments
# GitHub Environments — staging and production with protection rules
# Settings → Environments → production → Required reviewers
# Use environment-scoped secrets
jobs:
deploy-staging:
environment: staging
env:
API_URL: ${{ secrets.API_URL }} # From staging environment
deploy-production:
environment: production # Requires approval
needs: deploy-staging
env:
API_URL: ${{ secrets.API_URL }} # From production environment
# Reusable workflow — share CI logic across repositories
# .github/workflows/reusable-ci.yml
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
secrets:
NPM_TOKEN:
required: false
# Call from another workflow
jobs:
ci:
uses: myorg/workflows/.github/workflows/reusable-ci.yml@main
with:
node-version: '20'
secrets: inherit