tva
← Insights

React Deployment in 2026: Immutable Builds, Traefik, and Zero-Downtime Releases

A current, production-oriented follow-up that turns vendor guidance into operating controls, migration decisions, and verifiable release criteria.

What changed in 2026

We revisited this subject because the operating boundary has moved. The durable principles remain useful, but current versions make several old shortcuts incomplete or unsafe. This follow-up starts with the vendor documentation available on 14 July 2026, separates facts from local choices, and treats every configuration change as a controlled production intervention.

Current primary sources

How we use the update

Read the sources first, record the versions actually deployed, and define the observable result before changing a setting. Test the smallest reversible intervention in a representative environment. A successful command is not the acceptance criterion: service health, data integrity, latency, security boundaries, and rollback time are. Where the earlier guide offers a useful diagnostic sequence, we retain it below as an operating foundation; version-specific examples must still be checked against the current documentation. The configuration files and commands referenced below are in the original guide.

Operating foundation

Building a React application locally is straightforward. Deploying it correctly to production servers is 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 how to run npm run build and copy 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:

  • Disk-full 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 produces 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.

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 an nginx Docker container
  • Server Deployment: Upload and start the container on the 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
├── package-lock.json       # Locked dependency versions (npm ci)
├── 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, // Do not publish source maps in production
    // Minification is on by default (Oxc minifier in Vite 8)
    rolldownOptions: {
      output: {
        // Vite 8 (Rolldown): codeSplitting replaces the manualChunks object form
        codeSplitting: {
          groups: [
            { test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/, name: 'vendor' },
          ],
        },
      },
    },
  },
  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 the exact dependency versions from package-lock.json
npm ci
# Create production build
npm run build
# Verify build output
ls -lh dist/

Your dist/ folder should contain:

  • index.html – Entry point
  • assets/ – 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 (a location with its own add_header drops these)
    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";
    }
    # Revalidate HTML on every request so new releases show up immediately
    location ~* \.html$ {
        expires -1;
    }
    # Health check endpoint for monitoring
    location /health {
        access_log off;
        default_type text/plain;
        return 200 "healthy\n";
    }
}

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 (under 30MB compressed as of October 2026) 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. In our setup, adding custom HTTP routers interfered with this behavior and caused 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 can conflict with it.

Container Unhealthy Status

Symptom: docker compose ps shows the container as “unhealthy”.

Root Cause: The health check uses localhost, which resolves to IPv6 [::1], but nginx is 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 the 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 the server
  • Port 80/443 not accessible from the internet
  • Let’s Encrypt rate limits hit (for example, 5 certificates per exact set of hostnames or 50 per registered domain every 7 days)

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 2>&1 | 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 with minimal downtime. Recreating a single container with docker compose up still briefly interrupts traffic; true zero downtime needs a second healthy instance behind Traefik before the old one stops:

#!/bin/bash
# deploy-update.sh - rebuild and replace the container
# Recreating a single container briefly interrupts traffic (see above)
set -euo pipefail
cd /opt/my-react-app
# Pull latest code (from git or updated files)
git pull origin main
# Build new production assets from the lockfile
npm ci
npm run build
# Build the new image while the old container keeps serving
docker compose build my-react-app
# Replace the container: Compose stops the old one, then starts the new one,
# and --wait returns once the new container reports healthy
docker compose up -d --no-deps --wait my-react-app

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;

    # 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

An nginx server serving static React files requires minimal resources—512MB of memory and half a CPU core is a reasonable starting limit; load-test to confirm the concurrency your traffic needs.

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 separate containers and networks per client.

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@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: '24'
          cache: 'npm'

      - name: Build React app
        run: |
          npm ci
          npm run build

      - name: Deploy to server
        uses: appleboy/[email protected]
        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/[email protected]
        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 --wait

GitLab CI/CD Pipeline

For GitLab users:

# .gitlab-ci.yml
stages:
  - build
  - deploy
build:
  stage: build
  image: node:24
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour
deploy:
  stage: deploy
  image: alpine:3.23
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    # Trust the server's host key (store `ssh-keyscan your-server` output in SSH_KNOWN_HOSTS)
    - mkdir -p ~/.ssh && echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
  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 --wait"
  rules:
    - if: $CI_COMMIT_BRANCH == "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, above the server block: set Connection only for WebSocket upgrades
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# Inside the server block. Same-origin proxying: the browser makes no
# cross-origin request, so these API calls need no CORS headers
location /api {
    proxy_pass http://your-backend-service:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $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;
}

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
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

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.

From configuration to an operating decision

The practical question is not whether a platform can be configured. It is whether the team can explain ownership, detect drift, recover without improvisation, and demonstrate that the change improved the intended outcome. We therefore pair configuration with an owner, a baseline, a rollback path, and a readback window. That turns a one-off fix into an operating capability. The production record should include the reason for the change, the evidence reviewed, the exact scope, and the person authorized to stop or reverse it. After release, compare the agreed indicators with the baseline and keep the result with the change record. If the expected improvement is absent, rollback is a valid outcome rather than a failure. This discipline matters more than any individual configuration value. The same record gives the next operator a trustworthy starting point and makes later optimization a measured decision rather than another round of guesswork.

Related Insights

Further Reading