Deploying React Router v7 with Docker to VPS: A Complete CI/CD Guide
Introduction
Deploying a modern React Router v7 application to production requires more than just copying files to a server. In this comprehensive guide, I’ll walk you through the complete deployment setup I use for my portfolio, including Docker containerization, GitHub Actions CI/CD, and a Caddy reverse proxy on a VPS.
This guide uses React Router v7 with SSR (Server-Side Rendering), Docker for containerization, GitHub Actions for CI/CD, and Caddy as a reverse proxy with automatic HTTPS.
Why VPS instead of Vercel?
While platforms like Vercel or Netlify are excellent for getting started, I chose a VPS (Virtual Private Server) for this project to demonstrate infrastructure ownership. Mastering VPS deployment gives you:
-
Full Control: No vendor lock-in or platform-specific limitations.
-
Cost Efficiency: A fixed cost (e.g., $5/mo) regardless of bandwidth spikes or build minutes.
-
System Engineering Skills: Managing your own Docker containers, reverse proxies, and firewalls is a critical skill for senior engineers.
Architecture Overview
Here’s how our deployment pipeline works:
-
Local Development: Write code and test locally using
npm run dev. -
GitHub Actions Trigger: Manually trigger the deployment workflow from GitHub Actions.
-
Docker Build & Push: Build the Docker image and push it to Docker Hub.
-
VPS Deployment: The workflow SSHs into the VPS, pulls the latest image, and restarts containers.
-
Caddy Proxy: Caddy serves the app with automatic HTTPS and security headers.
Project Structure
First, let’s look at the relevant files in our project:
-
Dockerfile -
docker-compose.yml -
Caddyfile -
vite.config.docker.ts -
.github/workflows/deploy.yaml -
dockerignore
Step 1: Dockerfile Configuration
Our Dockerfile uses a multi-stage build to optimize the final image size. Here’s the complete setup:
# Stage 1: Build stageFROM node:20-alpine AS builder
# Set working directoryWORKDIR /app
# Copy package filesCOPY package*.json ./
# Install dependencies (clean install)RUN npm ci
# Copy application filesCOPY . .
# Build application using Docker-specific Vite configRUN npm run build -- --config vite.config.docker.ts
# Prune dev dependencies to keep the image smallRUN npm prune --production
# Stage 2: Production stageFROM node:20-slim AS runnerWORKDIR /app
# COPY the necessary filesCOPY --from=builder /app/build ./buildCOPY --from=builder /app/package.json ./package.jsonCOPY --from=builder /app/node_modules ./node_modules
# --- FIX START ---# Set the environment to productionENV NODE_ENV=production
# Use the built-in non-root user for securityUSER node
# Ensure the app listens on all interfaces (0.0.0.0)ENV PORT=3000EXPOSE 3000# --- FIX END ---
CMD ["npm", "run", "start"]💡 Tip: The multi-stage build pattern reduces the final image size by excluding build tools and dev dependencies from the production image. My final image dropped from ~900MB to ~120MB using this method.
Step 2: Docker Compose Configuration
The docker-compose.yml defines our production services:
services: portfolio-yafi: image: yaffalhakim1/portfolio-yafi:latest container_name: portfolio-yafi restart: unless-stopped ports: - '3000:3000' environment: - NODE_ENV=production networks: - web healthcheck: test: [ 'CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:3000/ health', ] interval: 30s timeout: 10s retries: 3 deploy: resources: limits: memory: 512M cpus: '1'
caddy: image: caddy:2-alpine container_name: caddy restart: unless-stopped ports: - '80:80' - '443:443' volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config networks: - web depends_on: - portfolio-yafi
networks: web: image: your-username/your-repo:latest ports: - "3000:3000" restart: always # --- FIX START --- healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/"] interval: 30s timeout: 10s retries: 3 start_period: 40s
volumes: caddy_data: caddy_config:Configuration Highlights:
-
Resource Limits: 512MB memory limit prevents a single container from crashing the entire server.
-
Health Checks: Automatically restarts the container if the app becomes unresponsive.
-
Bridge Network: Isolates our services from the outside world; only Caddy exposes ports 80/443.
Step 3: Vite Docker Configuration
Create a specialized Vite config for Docker builds (vite.config.docker.ts):
import { reactRouter } from '@react-router/dev/vite';import { defineConfig } from 'vite';import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ plugins: [reactRouter(), tsconfigPaths()], ssr: { // Crucial for packages that don't support SSR native ESM noExternal: ['react-slick', 'cobe', '@react-spring/web'], }, build: { target: 'esnext', },});Step 4: Caddy Reverse Proxy
Caddy provides automatic HTTPS and acts as a reverse proxy. Here’s the Caddyfile:
:80 { reverse_proxy portfolio-yafi:3000
# Security headers header { X-Content-Type-Options "nosniff" X-Frame-Options "DENY" X-XSS-Protection "1; mode=block" Referrer-Policy "strict-origin-when-cross-origin" }
# Enable compression encode gzip
# Logging log { output file /var/log/caddy/access.log format json }}Step 5: GitHub Actions CI/CD
The .github/workflows/deploy.yaml automates our deployment:
name: Deploy to VPS
on: workflow_dispatch:
jobs: build-and-deploy: runs-on: ubuntu-latest
steps: - name: Checkout code uses: actions/checkout@v4
# --- FIX START --- - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: your-username/your-repo:latest cache-from: type=gha # Use GitHub Actions cache cache-to: type=gha,mode=max
- name: Deploy to VPS uses: appleboy/ssh-action@master with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USERNAME }} key: ${{ secrets.VPS_SSH_KEY }} script: | cd /opt/portfolio docker compose pull docker compose up -d docker system prune -fRequired GitHub Secrets:
Create these Docker Hub related secrets:
-
DOCKER_USERNAME: Your Docker Hub username -
DOCKER_PASSWORD: Your Docker Hub Access Token (Security Tip: Do NOT use your real password. Generate a token!)
Create these SSH related secrets:
-
VPS_HOST: Server IP address -
VPS_USERNAME: User (e.g., ‘root’ or ‘ubuntu’) -
VPS_SSH_KEY: Private SSH Key content
Step 6: VPS Setup
On your VPS, prepare the environment:
# SSH into your VPSssh user@your-vps-ip
# Create deployment directorymkdir -p /opt/portfoliocd /opt/portfolio
# Create files (or scp them)touch docker-compose.yml CaddyfileSecurity & Maintenance
⚠️ Security Warning:
SSH Keys: Ensure your VPS only accepts SSH key authentication (disable password login).
Firewall: Use
ufwto allow only ports 22, 80, and 443.Secrets: Never commit
.envfiles or keys to GitHub.
VITE_ are baked into the code during the build step (on GitHub Actions). Variables used by the server (like DATABASE_URL) are read at runtime (on your VPS). If your client-side code needs a VITE_API_URL, you must include it in your GitHub Secrets and pass it as an --build-arg in your Dockerfile, otherwise it will be undefined in production.Conclusion
This setup provides a robust, automated deployment pipeline. By combining Docker for consistency, GitHub Actions for automation, and Caddy for security, we’ve built a production-grade infrastructure that rivals managed platforms, but with a fraction of the cost and significantly more control. The best part? Once configured, deploying is as simple as clicking “Run workflow” in GitHub Actions!
Related Resources
Official documentation for React Router v7 Learn more about containerization Automatic HTTPS reverse proxy
Have questions or suggestions? Feel free to reach out!