Building a React application locally is straightforward. Deploying it to production servers correctly? That’s where most developers hit unexpected roadblocks. This guide documents a real deployment debugging session where everything appeared configured correctly—container running, Traefik labels set, DNS resolving—yet the application returned persistent 404 errors.
Today, we’ll walk through the complete process of building React applications locally and deploying them to production Docker servers with proper reverse proxy configuration, automatic SSL, and professional domain routing. This approach builds on our philosophy of self-hosted solutions—similar to how we’ve shown you can self-host n8n for workflow automation and build multi-tenant development stacks for complete operational control.
The Problem with Traditional React Deployments
Most React deployment tutorials skip the critical production details. You’ll find guides showing npm run build
and copying files to nginx, but they rarely cover:
Configuration Conflicts:
- Custom HTTP routers that override global redirects
- Traefik label syntax errors that cause silent failures
- IPv6 vs IPv4 binding issues in health checks
- Missing service port mappings that produce 404 errors
Resource Management:
- Full disk errors preventing container registration
- Docker image bloat from unnecessary build artifacts
- Inefficient caching strategies that slow deployments
- Memory constraints affecting build performance
Production Readiness:
- Proper SSL certificate automation
- Zero-downtime deployment strategies
- Health check configurations
- Logging and monitoring integration
The result? Hours wasted debugging why a perfectly working local app returns mysterious 404 errors in production, even though “everything looks correct.”
The Tools We’re Using
Let’s understand what each piece does in our streamlined React deployment architecture:
Vite: Modern Build Tool
Vite provides lightning-fast development and optimized production builds. Unlike Create React App, Vite leverages native ES modules during development and rolls up highly optimized bundles for production. Your React app builds in seconds instead of minutes.
The key advantage? Vite automatically handles code splitting, tree shaking, and asset optimization. You get production-ready builds without complex webpack configurations.
Docker: Containerization for Consistency
Docker ensures your React app runs identically in development and production. The same nginx container serving your app locally will behave exactly the same on your production server—eliminating the classic “works on my machine” problem.
Think of Docker as packaging your entire application environment (React build files, nginx configuration, and runtime) into a portable container that works anywhere.
Traefik: Intelligent Reverse Proxy
Traefik acts as an intelligent traffic director, automatically routing requests to the correct containerized applications based on domain names. Instead of manually configuring complex nginx or Apache rules for every new application, Traefik reads labels from your Docker containers and sets up routing automatically.
In our multi-tenant Docker setup, we demonstrated Traefik’s power for managing multiple client environments. The same principles apply here for managing multiple React applications on a single server.
The beauty is Traefik handles SSL termination through Let’s Encrypt automatically, provides automatic service discovery, and offers detailed monitoring—all with minimal configuration.
nginx: Production Web Server
nginx serves your static React build files with exceptional performance. It’s the de facto standard for serving static content in production, handling thousands of concurrent connections efficiently while using minimal resources.
Understanding the Deployment Flow
Here’s the complete journey from local development to production:
- Local Development: Build and test your React app with
npm run dev
- Production Build: Create optimized static files with
npm run build
- Containerization: Package build files into nginx Docker container
- Server Deployment: Upload and start container on production server
- Traefik Registration: Automatic routing and SSL certificate provisioning
- Health Monitoring: Continuous health checks ensure availability
What makes this powerful is the automation. Once configured correctly, you can deploy updates in under 60 seconds with a single command.
Setting Up Your React Application
Project Structure for Production
Organize your React project with deployment in mind:
my-react-app/
├── src/ # React source code
├── public/ # Static assets
├── dist/ # Build output (auto-generated)
├── package.json # Dependencies
├── vite.config.ts # Vite configuration
├── Dockerfile # Container definition
├── nginx.conf # nginx configuration
└── docker-compose.yml # Deployment definition
Optimizing Your Vite Configuration
Create vite.config.ts
with production-optimized settings:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: false, // Disable in production for security
minify: 'terser',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
},
},
},
server: {
port: 3000,
host: true, // Enable network access
},
})
This configuration:
- Separates vendor libraries for better caching
- Minifies code for smaller file sizes
- Disables source maps in production (prevents code exposure)
- Optimizes chunk splitting for faster load times
Building for Production
Build your optimized production bundle:
# Install dependencies
npm install
# Create production build
npm run build
# Verify build output
ls -lh dist/
Your dist/
folder should contain:
index.html
– Entry pointassets/
– Minified JS, CSS, and images- Static files from
public/
Creating the Production Container
nginx Configuration for React
React applications use client-side routing, requiring special nginx configuration. Create nginx.conf
:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression for better performance
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/javascript application/json;
# 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;
# SPA: Route all paths to index.html for 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";
}
# No cache for HTML to ensure updates are immediate
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
# Health check endpoint for monitoring
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
The critical piece is try_files $uri $uri/ /index.html
which ensures React Router works correctly in production—all routes get served the main index.html
file.
Dockerfile for Production
Create an optimized Dockerfile
:
FROM nginx:alpine
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy production build files
COPY dist/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
This uses nginx:alpine for a minimal production image (only ~8MB) that contains everything needed to serve your React app.
Docker Compose Configuration
Create docker-compose.yml
for easy deployment:
services:
my-react-app:
build:
context: .
dockerfile: Dockerfile
container_name: my-react-app
restart: unless-stopped
networks:
- proxy
labels:
# Enable Traefik
- "traefik.enable=true"
# Define routing rule
- "traefik.http.routers.myapp.rule=Host(`app.yourdomain.com`)"
- "traefik.http.routers.myapp.entrypoints=https"
- "traefik.http.routers.myapp.tls=true"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
# Define service port
- "traefik.http.services.myapp.loadbalancer.server.port=80"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:80/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
networks:
proxy:
external: true
Critical Configuration Notes:
The labels section is where many deployments fail. Notice what we DON’T include:
- No separate HTTP router definition
- No custom redirect middleware
- No HTTP entrypoint configuration
Why? Because Traefik’s global configuration already handles HTTP→HTTPS redirects. Adding custom HTTP routers overrides this behavior and causes 404 errors—exactly the issue we solved in our debugging session.
Deploying to Your Production Server
Prerequisites on Your Server
Your production server needs:
Docker Environment:
# Verify Docker is installed
docker --version
docker compose --version
# Verify Traefik is running
docker ps | grep traefik
# Verify proxy network exists
docker network ls | grep proxy
If Traefik isn’t set up, refer to our multi-tenant Docker guide which covers comprehensive Traefik setup.
Sufficient Disk Space:
# Check available space
df -h /
# You need at least 2-5GB free for Docker operations
DNS Configuration:
- Point
app.yourdomain.com
to your server’s IP address - Wait for DNS propagation (usually 5-60 minutes)
Uploading Your Application
Transfer your application to the server:
# From your local machine
scp -r my-react-app/ user@your-server:/opt/
# Or use rsync for efficient updates
rsync -avz --exclude 'node_modules' \
my-react-app/ user@your-server:/opt/my-react-app/
Building and Starting the Container
SSH into your server and deploy:
# Navigate to application directory
cd /opt/my-react-app
# Build the Docker image
docker compose build
# Start the container
docker compose up -d
# Verify it's running
docker compose ps
Verifying the Deployment
Check that everything works:
# Test container health internally
docker compose exec my-react-app wget -q -O- http://127.0.0.1/health
# Check Traefik routing (wait 30 seconds for SSL)
curl -I https://app.yourdomain.com
# View container logs
docker compose logs -f
You should see HTTP/2 200
from the curl command, indicating success.
Common Deployment Failures and Solutions
404 Error Despite Correct Configuration
Symptom: Traefik returns HTTP/2 404
even though the container works internally.
Root Cause: Multiple router definitions for the same service without proper service port mapping, or custom HTTP routers overriding Traefik’s global redirects.
Solution:
# Remove these labels if present:
# - "traefik.http.middlewares.myapp-redirect.redirectscheme.scheme=https"
# - "traefik.http.routers.myapp-http.rule=Host(`app.yourdomain.com`)"
# - "traefik.http.routers.myapp-http.entrypoints=http"
# - "traefik.http.routers.myapp-http.middlewares=myapp-redirect"
# Keep only HTTPS router:
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.yourdomain.com`)"
- "traefik.http.routers.myapp.entrypoints=https"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
- "traefik.http.services.myapp.loadbalancer.server.port=80"
Traefik’s global HTTP→HTTPS redirect (configured in traefik.yml
) handles HTTP traffic automatically. Custom per-service HTTP routers create conflicts.
Container Unhealthy Status
Symptom: docker compose ps
shows container as “unhealthy”
Root Cause: Health check using localhost
which resolves to IPv6 [::1]
, but nginx only listening on IPv4.
Solution:
healthcheck:
# Use explicit IPv4 address instead of localhost
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:80/health"]
Docker Build Fails with “No Space Left”
Symptom: Build fails with disk space errors
Solution:
# Check disk usage
df -h /
# Clean Docker system
docker system prune -a -f
# Remove unused images
docker image prune -a -f
# Remove unused volumes
docker volume prune -f
If disk is truly full (>95%), you need to free space or expand your storage. Docker operations require temporary space for layer caching and building.
SSL Certificate Not Generating
Symptom: Curl shows self-signed certificate after 10+ minutes
Common Causes:
- DNS not properly pointed to server
- Port 80/443 not accessible from internet
- Let’s Encrypt rate limits hit (5 per domain per week)
Solution:
# Verify DNS resolution
dig app.yourdomain.com
# Test port accessibility
curl -I http://app.yourdomain.com
# Check Traefik logs for ACME errors
docker logs traefik | grep -i acme
# Restart Traefik if needed
docker restart traefik
React Router 404 Errors on Refresh
Symptom: App works on initial load but shows 404 when refreshing on routes like /about
Root Cause: Missing try_files
directive in nginx configuration
Solution: Ensure your nginx.conf
includes:
location / {
try_files $uri $uri/ /index.html;
}
This tells nginx to serve index.html
for all routes, letting React Router handle the routing client-side.
Container Starts But Traefik Can’t Reach It
Symptom: Container runs but Traefik returns “Service Unavailable”
Solution:
# Verify container is on correct network
docker network inspect proxy
# Check if container is listed
docker inspect my-react-app | grep -A 20 Networks
# Ensure proxy network exists
docker network create proxy
Optimizing for Production
Implementing Zero-Downtime Deployments
Update your application without downtime:
#!/bin/bash
# deploy-update.sh - Zero-downtime deployment
cd /opt/my-react-app
# Pull latest code (from git or updated files)
git pull origin main
# Build new production assets
npm install
npm run build
# Build new Docker image
docker compose build
# Start new container (old one still running)
docker compose up -d --no-deps --build my-react-app
# Traefik automatically routes to healthy container
# Old container is automatically stopped after new one is healthy
Advanced Health Checks
Implement comprehensive health monitoring:
healthcheck:
test: |
wget --no-verbose --tries=1 --spider http://127.0.0.1:80/health &&
wget --no-verbose --tries=1 --spider http://127.0.0.1:80/
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
This checks both the health endpoint AND the main application route, ensuring the entire app is responding correctly.
Performance Optimization
Fine-tune nginx for better performance:
# Add to nginx.conf
server {
# ... existing config ...
# Increase buffer sizes for large headers
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Enable keep-alive connections
keepalive_timeout 65;
keepalive_requests 100;
# Optimize file serving
sendfile on;
tcp_nopush on;
tcp_nodelay on;
}
Resource Limits
Prevent resource exhaustion with container limits:
services:
my-react-app:
# ... existing config ...
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
nginx serving static React files requires minimal resources—512MB memory and half a CPU core handles thousands of concurrent users.
Why Self-Hosted Docker Deployments Matter
Self-hosting your React applications on Docker infrastructure gives you complete control over your deployment pipeline without vendor lock-in. You can deploy unlimited applications on your own infrastructure, customize every aspect of the deployment process, and integrate seamlessly with your existing self-hosted services.
This approach works particularly well when combined with our multi-tenant Docker architecture, where you can host multiple client applications on the same infrastructure with complete isolation.
Automation and CI/CD Integration
GitHub Actions Deployment
Automate deployments on every push:
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Build React app
run: |
npm ci
npm run build
- name: Deploy to server
uses: appleboy/scp-action@v0.1.4
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "dist/,Dockerfile,nginx.conf,docker-compose.yml"
target: "/opt/my-react-app"
- name: Restart container
uses: appleboy/ssh-action@v0.1.10
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/my-react-app
docker compose up -d --build
GitLab CI/CD Pipeline
For GitLab users:
# .gitlab-ci.yml
stages:
- build
- deploy
build:
stage: build
image: node:18
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
script:
- scp -r dist/ Dockerfile nginx.conf docker-compose.yml $SERVER_USER@$SERVER_HOST:/opt/my-react-app/
- ssh $SERVER_USER@$SERVER_HOST "cd /opt/my-react-app && docker compose up -d --build"
only:
- main
Monitoring Your Production Deployment
Logging Strategy
Implement comprehensive logging:
# Add to docker-compose.yml
services:
my-react-app:
# ... existing config ...
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
View logs efficiently:
# Real-time logs
docker compose logs -f my-react-app
# Last 100 lines
docker compose logs --tail=100 my-react-app
# Logs from specific time
docker compose logs --since=2h my-react-app
# Filter for errors only
docker compose logs my-react-app | grep -i error
Metrics and Alerts
Monitor container health:
#!/bin/bash
# health-check.sh - Regular health monitoring
CONTAINER="my-react-app"
WEBHOOK_URL="your-notification-webhook"
STATUS=$(docker inspect --format='{{.State.Health.Status}}' $CONTAINER)
if [ "$STATUS" != "healthy" ]; then
curl -X POST $WEBHOOK_URL \
-H 'Content-Type: application/json' \
-d "{\"text\":\"⚠️ Container $CONTAINER is $STATUS\"}"
fi
Run this via cron every 5 minutes for basic monitoring.
Connecting React to Backend Infrastructure
Your React app likely needs to communicate with backend services. This integrates naturally with self-hosted infrastructure. If you’re running n8n for workflow automation or Windmill for backend workflows, configure proper CORS and API routing in your nginx configuration:
# Add to nginx.conf for API proxying
location /api {
proxy_pass http://your-backend-service:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
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;
}
This works seamlessly when all services are part of the same Docker network, as demonstrated in our multi-tenant architecture guide.
Environment-Specific Builds
Different environments often need different configurations:
// vite.config.ts
export default defineConfig(({ mode }) => ({
plugins: [react()],
define: {
'import.meta.env.VITE_API_URL': JSON.stringify(
mode === 'production'
? 'https://api.yourdomain.com'
: 'http://localhost:3000'
),
},
build: {
outDir: 'dist',
sourcemap: mode !== 'production',
},
}))
Build for different environments:
# Development build
npm run build -- --mode development
# Staging build
npm run build -- --mode staging
# Production build
npm run build -- --mode production
The Real Value of Understanding This Setup
This deployment approach matters if you’re:
Managing Multiple Applications:
- Deploy React apps alongside backend services on the same infrastructure
- Use consistent deployment processes across all projects
- Integrate with self-hosted tools like n8n and Windmill
Building for Clients:
- Professional SSL-secured custom domains
- Complete control over infrastructure and deployments
- No platform limitations or vendor lock-in
Learning Infrastructure:
- Understand Docker containerization fundamentals
- Master Traefik reverse proxy configuration
- Debug production deployment issues systematically
The setup documented here is based on a real debugging session—the problems described actually happened, and the solutions actually worked. This makes it more valuable than theoretical tutorials because you’re seeing the actual pitfalls and how to avoid them.
When combined with our multi-tenant Docker architecture, this forms a foundation for scalable, self-hosted application delivery that you fully control.
Related Resources
For more self-hosted infrastructure guides, check out these resources:
- Self-hosting n8n for workflow automation – Automate deployments and infrastructure tasks
- Self-hosting Windmill with Docker – Alternative workflow automation platform
- Building multi-tenant Docker stacks – Comprehensive Traefik setup for multiple applications
- tva Duplicate Pro – WordPress automation tools for content workflows
These guides demonstrate different aspects of building self-hosted infrastructure that gives you complete control while maintaining professional standards.
Get Professional Support
Setting up production-grade React deployments involves many infrastructure considerations. While we’ve provided comprehensive documentation, every project has unique requirements, existing infrastructure constraints, and specific performance needs.
If you’re implementing React deployment infrastructure for production use or need customization for your specific client delivery needs, we can help with:
- Custom deployment pipelines tailored to your workflow
- Integration with existing CI/CD systems
- Performance optimization for high-traffic applications
- Multi-region deployment strategies
- Team training on Docker and Traefik best practices
- Ongoing infrastructure management and monitoring
Contact us through tva.sg/contact to discuss your React deployment needs and get professional guidance on implementation.
Whether you’re scaling an existing agency, launching a new SaaS product, or building enterprise-grade client delivery capabilities, we’re here to help you succeed with self-hosted, containerized React deployments that maintain your independence while delivering professional results.