Skip to main content

React Application Deployment: GitHub Actions to VPS with Nginx

React applications require build processes that transform JSX and modern JavaScript into browser-compatible bundles. This guide covers automated deployment strategies for React applications built with popular toolchains including Vite, Create React App, and Next.js.

Understanding React Deployment: Complete Beginner's Guide

What you'll learn:

  • Why React applications need a "build" process before deployment
  • How different React frameworks (Vite, CRA, Next.js) work
  • How to set up automated deployment with GitHub Actions
  • How to configure Nginx to serve React applications properly
  • How to handle client-side routing and environment variables

Prerequisites:

Why do React applications need deployment? When you develop React applications locally, you use development tools that:

  • Compile JSX into regular JavaScript (browsers don't understand JSX)
  • Bundle multiple files into optimized packages
  • Enable hot reloading for instant updates
  • Include debugging tools and error messages

For production deployment, you need to:

  • Build the application: Transform development code into optimized, browser-ready files
  • Serve static files: Use a web server (like Nginx) to deliver files to users
  • Handle routing: Configure the server to work with React Router
  • Optimize performance: Minify code, compress files, and cache resources

Real-world analogy: Think of React development like cooking in a professional kitchen vs. serving food in a restaurant:

  • Development: You have all the ingredients, prep tools, and equipment to experiment and make changes quickly
  • Production: You need to prepare finished dishes that are properly plated, optimized for serving, and ready for customers

Understanding React Build Requirements

Build Output Characteristics: Understanding What Gets Created

Static Site Generation (Most Common Approach)

What is Static Site Generation? Static site generation means your React application gets "compiled" into regular HTML, CSS, and JavaScript files that any web server can serve. This is like converting a recipe into a pre-made meal—everything is prepared and ready to serve.

Popular React frameworks and their output:

  • Vite: Creates optimized files in dist/ directory
  • Create React App (CRA): Creates optimized files in build/ directory
  • Next.js (Static Export): Creates optimized files in out/ directory

What's inside these build directories?

build/ (or dist/ or out/)
├── index.html # Main HTML file
├── static/
│ ├── css/
│ │ └── main.abc123.css # Compiled and minified CSS
│ └── js/
│ ├── main.xyz789.js # Your React app code (minified)
│ └── vendor.def456.js # React libraries (minified)
└── favicon.ico # Website icon

Why static generation is popular:

  • Fast loading: Pre-built files load quickly
  • Simple hosting: Any web server (Nginx, Apache) can serve these files
  • CDN friendly: Files can be cached globally for better performance
  • Cost effective: No server processing required

Server-Side Rendering (Advanced Approach)

What is Server-Side Rendering (SSR)? SSR means your React application runs on a Node.js server that generates HTML for each request. This is like a restaurant kitchen that prepares meals to order—each request gets a fresh, customized response.

Frameworks that require servers:

  • Next.js (Full-Stack): Requires Node.js server for SSR, API routes, and dynamic features
  • Remix: Requires Node.js server with platform adapters

When you need SSR:

  • Dynamic content that changes based on user authentication
  • Server-side data fetching for SEO optimization
  • Real-time features like chat or live updates
  • Complex backend integration requirements

SSR deployment considerations:

  • Requires Node.js runtime on the server
  • More complex server configuration
  • Higher server resource usage
  • Better SEO for dynamic content

Deployment Considerations

Client-Side Routing React Router and similar libraries require server configuration to handle routes properly. Without proper setup, direct navigation to /about results in 404 errors.

Environment Variables Build-time environment variables must be available during the GitHub Actions build process, not just on the server.

Asset Optimization Modern React builds include automatic code splitting, tree shaking, and asset optimization that require proper server configuration for optimal performance.

Vite React Application Deployment

Project Structure Requirements

Essential files for CI/CD:

your-react-app/
├── package.json # Dependencies and scripts
├── vite.config.js # Build configuration
├── .github/
│ └── workflows/
│ ├── ci.yml # Continuous Integration
│ └── deploy.yml # Continuous Deployment
├── src/
│ └── ... # Your React application
└── public/ # Static assets

Vite Configuration for Production

Why optimize Vite for production? Vite's default settings work great for development, but production deployments need optimization for speed and security.

vite.config.js optimized for deployment:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [react()],

// Build configuration
build: {
outDir: "dist", // Output folder name
sourcemap: false, // Don't include source maps (security)
minify: "terser", // Compress JavaScript files
rollupOptions: {
output: {
manualChunks: {
// Split code into separate files
vendor: ["react", "react-dom"], // React libraries
router: ["react-router-dom"], // Routing library
},
},
},
},

// Base path for assets (use "/" for root domain)
base: "/",

// Environment variables available in your app
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
},

// Preview server configuration (for testing)
preview: {
port: 4173,
host: true,
},
});

GitHub Actions Workflow for Vite

.github/workflows/deploy-vite-react.yml:

name: Deploy Vite React App

on:
push:
branches: [main]
workflow_dispatch:

jobs:
build-and-deploy:
runs-on: ubuntu-latest

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

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm run test -- --run
env:
CI: true

- name: Build for production
run: npm run build
env:
VITE_API_URL: ${{ secrets.VITE_API_URL }}
VITE_APP_TITLE: ${{ secrets.VITE_APP_TITLE }}
VITE_ANALYTICS_ID: ${{ secrets.VITE_ANALYTICS_ID }}

- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

- name: Deploy to server
run: |
# Create deployment directory on server if it doesn't exist
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
sudo mkdir -p /var/www/${{ secrets.APP_NAME }}
sudo chown ${{ secrets.SSH_USER }}:www-data /var/www/${{ secrets.APP_NAME }}
"

# Deploy built files
scp -r ./dist/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/${{ secrets.APP_NAME }}/

# Set proper permissions
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
sudo chown -R www-data:www-data /var/www/${{ secrets.APP_NAME }}
sudo chmod -R 755 /var/www/${{ secrets.APP_NAME }}
"

- name: Reload Nginx
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "sudo systemctl reload nginx"

- name: Verify deployment
run: |
sleep 10
HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" ${{ secrets.APP_URL }})
if [ $HTTP_CODE -eq 200 ]; then
echo "✅ Deployment successful - HTTP $HTTP_CODE"
else
echo "❌ Deployment verification failed - HTTP $HTTP_CODE"
exit 1
fi

Nginx Configuration for Vite React Apps

/etc/nginx/sites-available/vite-react-app:

server {
listen 80;
server_name your-domain.com www.your-domain.com;

root /var/www/vite-react-app;
index index.html;

# Logging
access_log /var/log/nginx/vite-react-app-access.log;
error_log /var/log/nginx/vite-react-app-error.log;

# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml+rss
image/svg+xml;

# Handle React Router (client-side routing)
location / {
try_files $uri $uri/ /index.html;
}

# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}

# Cache HTML files for short periods
location ~* \.html$ {
expires 5m;
add_header Cache-Control "public, must-revalidate, proxy-revalidate";
}

# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Disable access to source maps in production
location ~ \.map$ {
return 404;
}

# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

Create React App Deployment

Build Configuration Differences

Key differences from Vite:

  • Build output goes to build/ directory
  • Uses react-scripts for building
  • Environment variables must be prefixed with REACT_APP_

GitHub Actions for Create React App

.github/workflows/deploy-cra.yml:

name: Deploy Create React App

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test -- --coverage --watchAll=false
env:
CI: true

- name: Build application
run: npm run build
env:
REACT_APP_API_URL: ${{ secrets.REACT_APP_API_URL }}
REACT_APP_VERSION: ${{ github.sha }}
GENERATE_SOURCEMAP: false

- name: Deploy to server
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

# Note: using 'build' directory instead of 'dist'
scp -r ./build/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/${{ secrets.APP_NAME }}/

ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
sudo chown -R www-data:www-data /var/www/${{ secrets.APP_NAME }}
sudo systemctl reload nginx
"

Next.js Application Deployment

Static Export Deployment

For Next.js apps that don't require server-side rendering:

next.config.js for static export:

/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable static export
output: "export",

// Disable image optimization (not supported in static export)
images: {
unoptimized: true,
},

// Base path for subdirectory deployment (optional)
basePath: process.env.NODE_ENV === "production" ? "/app" : "",

// Asset prefix for CDN (optional)
assetPrefix: process.env.NODE_ENV === "production" ? "/app" : "",

// Trailing slash
trailingSlash: true,

// Environment variables
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
},
};

module.exports = nextConfig;

GitHub Actions for Next.js Static:

name: Deploy Next.js Static

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Build Next.js app
run: npm run build
env:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

- name: Deploy static files
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

# Note: using 'out' directory for Next.js static export
scp -r ./out/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/${{ secrets.APP_NAME }}/

ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "sudo systemctl reload nginx"

Full-Stack Next.js Deployment

For Next.js apps with API routes and SSR:

GitHub Actions for Full Next.js:

name: Deploy Next.js Full-Stack

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Build Next.js application
run: npm run build
env:
NODE_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

- name: Create deployment package
run: |
mkdir deployment
cp -r .next deployment/
cp -r public deployment/
cp package.json deployment/
cp package-lock.json deployment/
cp next.config.js deployment/

# Create ecosystem file for PM2
cat > deployment/ecosystem.config.js << EOF
module.exports = {
apps: [{
name: '${{ secrets.APP_NAME }}',
script: 'node_modules/.bin/next',
args: 'start -p ${{ secrets.APP_PORT || 3000 }}',
instances: 1,
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: ${{ secrets.APP_PORT || 3000 }}
}
}]
}
EOF

- name: Deploy to server
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

# Upload deployment package
scp -r deployment/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/${{ secrets.APP_NAME }}/

# Install dependencies and restart application
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
cd /var/www/${{ secrets.APP_NAME }}
npm ci --production
pm2 reload ecosystem.config.js --env production
"

Nginx configuration for Next.js server:

server {
listen 80;
server_name nextjs-app.example.com;

location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;

# Timeout settings
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}

# Handle Next.js static assets
location /_next/static {
proxy_pass http://localhost:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
}

Environment Variables Management

Build-Time vs Runtime Variables

Build-time variables (embedded in bundle):

# In GitHub Actions
env:
VITE_API_URL: ${{ secrets.VITE_API_URL }} # Vite
REACT_APP_API_URL: ${{ secrets.REACT_APP_API_URL }} # CRA
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} # Next.js

Runtime variables (server-side only):

# For Next.js server-side
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

Environment-Specific Configurations

Multi-environment deployment:

name: Multi-Environment React Deployment

on:
push:
branches: [main, develop]

jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging

steps:
- uses: actions/checkout@v4
# ... setup steps

- name: Build for staging
run: npm run build
env:
VITE_API_URL: https://api-staging.yourapp.com
VITE_ENVIRONMENT: staging

- name: Deploy to staging server
run: |
# Deploy to staging directory/server
scp -r ./dist/* ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_SSH_HOST }}:/var/www/staging/

deploy-production:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production

steps:
- uses: actions/checkout@v4
# ... setup steps

- name: Build for production
run: npm run build
env:
VITE_API_URL: https://api.yourapp.com
VITE_ENVIRONMENT: production
VITE_ANALYTICS_ID: ${{ secrets.PRODUCTION_ANALYTICS_ID }}

- name: Deploy to production server
run: |
scp -r ./dist/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/production/

Advanced Deployment Features

Blue-Green Deployment for React Apps

name: Blue-Green React Deployment

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup and build
run: |
npm ci
npm run build

- name: Deploy to green slot
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

# Deploy to green directory
scp -r ./dist/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/green/

- name: Health check on green deployment
run: |
# Test green deployment
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
# Temporarily point test domain to green
sudo sed 's|/var/www/blue|/var/www/green|' /etc/nginx/sites-available/test-app > /tmp/nginx-green
sudo mv /tmp/nginx-green /etc/nginx/sites-available/test-app
sudo systemctl reload nginx
"

sleep 10

# Test the green deployment
if curl -f https://test.yourapp.com; then
echo "✅ Green deployment healthy"
else
echo "❌ Green deployment failed"
exit 1
fi

- name: Switch to green (go live)
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
# Backup current blue
sudo mv /var/www/blue /var/www/blue-backup-$(date +%Y%m%d-%H%M%S)

# Switch green to blue (live)
sudo mv /var/www/green /var/www/blue

# Update main site to point to blue
sudo sed 's|/var/www/green|/var/www/blue|' /etc/nginx/sites-available/main-app > /tmp/nginx-blue
sudo mv /tmp/nginx-blue /etc/nginx/sites-available/main-app
sudo systemctl reload nginx
"

- name: Verify production deployment
run: |
sleep 15
if curl -f https://yourapp.com; then
echo "✅ Blue-green deployment successful"
else
echo "❌ Production verification failed"
exit 1
fi

Rollback Strategy

name: Rollback React App

on:
workflow_dispatch:
inputs:
backup_timestamp:
description: "Backup timestamp to restore (YYYYMMDD-HHMMSS)"
required: true

jobs:
rollback:
runs-on: ubuntu-latest

steps:
- name: Rollback deployment
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
# Check if backup exists
if [ -d '/var/www/blue-backup-${{ github.event.inputs.backup_timestamp }}' ]; then
# Create backup of current version
sudo mv /var/www/blue /var/www/blue-rollback-$(date +%Y%m%d-%H%M%S)

# Restore from backup
sudo cp -r /var/www/blue-backup-${{ github.event.inputs.backup_timestamp }} /var/www/blue

# Reload nginx
sudo systemctl reload nginx

echo '✅ Rollback completed'
else
echo '❌ Backup not found: ${{ github.event.inputs.backup_timestamp }}'
exit 1
fi
"

- name: Verify rollback
run: |
sleep 10
if curl -f https://yourapp.com; then
echo "✅ Rollback verification successful"
else
echo "❌ Rollback verification failed"
exit 1
fi

Required GitHub Secrets

Basic Deployment Secrets

Secret NameDescriptionExample
SSH_PRIVATE_KEYPrivate SSH key-----BEGIN RSA PRIVATE KEY-----...
SSH_USERSSH usernamedeploy
SSH_HOSTServer IP/domain192.168.1.100
APP_NAMEApplication directory namemy-react-app
APP_URLApplication URLhttps://myapp.com

Environment Variables

FrameworkSecret ExampleUsage
ViteVITE_API_URLAPI endpoint URL
Create React AppREACT_APP_API_URLAPI endpoint URL
Next.jsNEXT_PUBLIC_API_URLPublic API endpoint
Next.jsDATABASE_URLServer-side database connection

Multi-Environment Secrets

Secret NameEnvironmentDescription
STAGING_SSH_HOSTStagingStaging server address
PRODUCTION_API_URLProductionProduction API URL
STAGING_API_URLStagingStaging API URL

Common Deployment Issues

Build Failures

Memory issues during build:

- name: Build with increased memory
run: npm run build
env:
NODE_OPTIONS: "--max-old-space-size=4096"

Missing environment variables:

- name: Validate environment variables
run: |
if [ -z "$VITE_API_URL" ]; then
echo "❌ VITE_API_URL not set"
exit 1
fi
env:
VITE_API_URL: ${{ secrets.VITE_API_URL }}

Routing Issues

Nginx returns 404 for React Router paths:

  • Ensure try_files $uri $uri/ /index.html; is configured
  • Check that index.html exists in the deployment directory

Base path configuration issues:

// Vite - ensure base matches deployment path
export default defineConfig({
base: '/my-app/', // If deployed to subdirectory
})

// Create React App - use PUBLIC_URL
"homepage": "/my-app" // in package.json

Performance Optimization

Asset loading optimization:

# Preload critical resources
location = /index.html {
add_header Link "</assets/main.css>; rel=preload; as=style";
add_header Link "</assets/main.js>; rel=preload; as=script";
try_files $uri =404;
}

# Service worker caching
location = /service-worker.js {
expires off;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}

This comprehensive guide covers the most common React deployment scenarios using automated GitHub Actions workflows. The key is understanding your build tool's output structure and configuring both the CI/CD pipeline and server environment to handle React's specific requirements, particularly client-side routing and environment variable management.