- Vite Docker: Production Deployment with Nginx, SSL, and Compose
Vite Docker: Production Deployment with Nginx, SSL, and Compose
Dockerfile, Nginx config, SSL, and docker-compose for VPS deployment

🐳 Docker & DevOps Implementation Guides
Complete Docker guides with optimization techniques, deployment strategies, and automation prompts to streamline your containerization workflow.
Related Posts:
I was building a client application using React and Vite when I hit a wall trying to deploy it to production on a VPS. The development setup worked perfectly, but getting everything running in production with proper SSL certificates, Docker containers, and NGINX proved much more challenging than expected. After spending days figuring out the Docker networking, SSL certificate mounting, and NGINX configuration for Vite's specific asset handling, I finally got a rock-solid production setup running.
This guide walks you through the exact implementation I developed - a complete Docker Compose setup that serves your React Vite app through NGINX with automatic SSL certificates and proper API proxying. By the end, you'll have a production-ready deployment that handles SSL termination, static asset serving, and backend API routing all through a single Docker container.
Quick reference — what this guide gives you:
| Layer | What's used |
|---|---|
| Builder image | node:24-slim — active LTS (Node 24), avoids glibc issues of Alpine |
| Runtime image | nginx:stable-alpine — ~25–30MB final image |
| SSL | Let's Encrypt via Certbot, mounted read-only from host |
| Env vars | VITE_ variables injected at build time via Docker ARG |
| Caching | Hashed Vite assets cached forever; index.html never cached |
| API routing | /api/* proxied to backend service via Nginx |
| New to Vite's production build output? There's a short primer below before the Docker setup. |
Project Structure and Prerequisites
This implementation assumes you have a React Vite application created in a frontend/ folder within your project root. The Docker Compose configuration sits in the root directory, allowing you to easily add additional services like backend APIs, databases, or other microservices to your stack.
All CLI commands in this guide use docker compose (V2 plugin, space not hyphen). If you're on an older system with the standalone V1 binary, replace docker compose with docker-compose.
project-root/
├── frontend/ # Your React Vite application
│ ├── src/
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── vite.config.ts
│ ├── Dockerfile
│ └── nginx.conf
├── compose.yaml # Docker Compose orchestration
└── backend/ # Optional: Your backend services
This folder structure provides clean separation of concerns while enabling easy service expansion as your application grows.
Vite Production Build Basics
Before containerising anything, it helps to know what vite build actually produces. Running pnpm run build (or npm run build) outputs a dist/ folder containing a single index.html and a flat /assets/ directory of hashed .js and .css files — for example, index-B7id5rbw.js. There is no Node server involved. The output is pure static files.
This matters for Docker: you don't need a Node runtime in production. You need a static file server. Nginx is the right tool — lightweight, battle-tested for this exact job, and the reason the multi-stage Dockerfile discards the Node builder stage entirely before producing the final image.
If your Vite app makes API calls, those go to a separate backend service. Nginx handles the proxy — covered in the configuration section below.
The Challenge with React Vite Production Deployments
Docker Multi-Stage Build Strategy
The foundation of this deployment is a multi-stage Docker build that separates the build environment from the production runtime. This approach keeps the final image small while ensuring all dependencies are properly handled.
# File: frontend/Dockerfile FROM node:24-slim AS builder WORKDIR /app RUN npm install -g pnpm@latest-10 COPY package*.json ./ COPY pnpm-lock.yaml ./ RUN pnpm install COPY . . ARG VITE_API_URL ENV VITE_API_URL=$VITE_API_URL RUN pnpm run build FROM nginx:stable-alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf RUN chmod -R 755 /usr/share/nginx/html
This Dockerfile uses two distinct stages. The builder stage uses Node 24 LTS (node:24-slim) — the current Active LTS release as of March 2026. node:24-slim is preferred over node:24-alpine for production builds: Alpine uses musl libc instead of glibc, which can cause silent failures with native dependencies. Slim gives you a small image without the compatibility risk. The production stage uses nginx:stable-alpine, keeping the final image around 25–30MB.
Not using pnpm? Swap the pnpm lines for npm: remove RUN npm install -g pnpm@latest-10 and replace RUN pnpm install with RUN npm ci and RUN pnpm run build with RUN npm run build. The rest of the Dockerfile is identical.
Not using pnpm? Swap the pnpm lines for npm: remove RUN npm install -g pnpm and replace RUN pnpm install with RUN npm ci and RUN pnpm run build with RUN npm run build. The rest of the Dockerfile is identical.
How to Dockerize a Vite App: Environment Variables and Build Config
Vite requires specific configuration adjustments to work properly in a Dockerized production environment. The key challenge is ensuring asset paths resolve correctly when served through NGINX.
Vite Environment Variables in Docker
This is the most common Vite + Docker deployment blocker. Vite's VITE_ prefix variables are compile-time constants — they get baked into the JavaScript bundle during vite build. They are not read at runtime like Node environment variables.
This means you cannot pass them via docker run -e or environment: in Compose and expect them to appear in your app. They must be available during the build step.
The correct pattern uses Docker build arguments:
# In your Dockerfile ARG VITE_API_URL ENV VITE_API_URL=$VITE_API_URL RUN pnpm run build
Then pass them at build time:
docker build --build-arg VITE_API_URL=https://api.yourdomain.com .
Or in Compose:
services:
frontend:
build:
context: ./frontend
args:
VITE_API_URL: https://api.yourdomain.com
Do not store secrets in VITE_ variables. They are embedded in the JavaScript bundle and visible to anyone who inspects your build output. VITE_ is for public configuration only — API base URLs, feature flags, public keys.
Vite Config for Production
// File: frontend/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from "path"
export default defineConfig({
base: './',
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
}
}
},
plugins: [react()],
build: {
rollupOptions: {},
commonjsOptions: {
include: [/node_modules/],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
dedupe: ['react', 'react-dom', 'prop-types'],
},
optimizeDeps: {
include: ['react', 'react-dom', 'prop-types'],
},
})
The base: './' setting is the most important configuration for production deployments — without it, your app will load a blank white page after deploy because all asset paths will be absolute and break when served from a Docker container behind Nginx. The development proxy configuration shows how API calls are handled locally. In production, Nginx takes over this proxy responsibility.
The alias configuration with "@": path.resolve(__dirname, "./src") allows clean import paths throughout your application, while the dedupe settings prevent multiple versions of core React libraries from ending up in the bundle.
NGINX Production Configuration
The NGINX configuration handles multiple critical functions: SSL termination, static asset serving, API proxying, and single-page application routing. This configuration file replaces the default NGINX setup and provides enterprise-level capabilities.
# File: frontend/nginx.conf server { listen 80; listen [::]:80; server_name your-domain.com www.your-domain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name your-domain.com www.your-domain.com; ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets off; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers off; root /usr/share/nginx/html; index index.html; include /etc/nginx/mime.types; types { application/javascript mjs js; } gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
The first server block handles HTTP to HTTPS redirection, ensuring all traffic uses encrypted connections. The SSL certificate paths reference Let's Encrypt certificates - the complete process for setting up automatic SSL certificate generation and renewal is covered in detail in my separate guide: How to Set Up Automatic SSL Certificate Renewal with Certbot in Docker Containers.
The SSL configuration uses modern TLS protocols and cipher suites for maximum security. The session cache settings optimize SSL handshake performance for returning visitors.
The MIME type configuration is particularly important for Vite applications because it explicitly handles modern JavaScript modules and ensures proper content-type headers. The gzip settings compress static assets to reduce bandwidth usage and improve loading times.
API Proxy and Asset Handling
The most complex part of the NGINX configuration involves routing API requests to your backend while serving static assets efficiently. This setup allows your frontend to make API calls to /api/* paths that get transparently proxied to your backend service.
# File: frontend/nginx.conf (continued) # API proxy location /api/ { rewrite ^/api/(.*)$ /$1 break; proxy_pass http://backend:8000; proxy_http_version 1.1; 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_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-SECRET-KEY "<YOUR_SECRET_KEY>"; proxy_cache_bypass $http_upgrade; proxy_buffering off; proxy_read_timeout 60s; proxy_send_timeout 60s; proxy_connect_timeout 60s; } # Assets - ONE simple block for all static files # Assets - Vite hashes filenames (e.g. index-B7id5rbw.js) so they're immutable location /assets/ { root /usr/share/nginx/html; autoindex off; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } # Handle all other routes - no-cache on index.html so deploys take effect immediately location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } server_tokens off; } The API proxy strips the `/api` prefix and forwards requests to the backend service on port 8000. The `X-SECRET-KEY` header is an optional internal authentication mechanism — useful for ensuring only your Nginx frontend can reach your backend. For user-facing authentication, use JWT or OAuth instead. The asset caching strategy uses two distinct tiers. The `/assets/` block sets `max-age=31536000, immutable` — safe to do because Vite hashes every filename (e.g., `index-B7id5rbw.js`), so a new deploy produces new filenames and cache invalidation is automatic. The root `location /` block sets `no-cache` on `index.html`, ensuring the browser always fetches the latest entry point on each visit. This combination gives you aggressive caching without stale deploy problems. The final `try_files $uri $uri/ /index.html` fallback implements SPA routing — any path that doesn't match a file on disk falls through to `index.html`, letting React Router handle it client-side. ## Docker Compose Orchestration The Docker Compose configuration ties everything together, managing the container lifecycle, network connectivity, and volume mounting for SSL certificates. ```yaml # File: compose.yaml services: frontend: build: context: ./frontend dockerfile: Dockerfile args: VITE_API_URL: https://api.yourdomain.com ports: - "80:80" - "443:443" networks: - app-network volumes: - /etc/letsencrypt:/etc/letsencrypt:ro depends_on: - backend volumes: frontend-node-modules: networks: app-network: driver: bridge
This configuration builds your frontend container from the local Dockerfile and exposes both HTTP and HTTPS ports. The critical volume mount /etc/letsencrypt:/etc/letsencrypt:ro provides read-only access to SSL certificates generated by Let's Encrypt on the host system.
Move SECRET_KEY and other sensitive values out of compose.yaml before committing to version control. Use an .env file in your project root and reference it with env_file: .env in your Compose service, or use Docker Secrets for production deployments.
For alternative deployment approaches without traditional SSL setup, see my guide on Cloudflare Tunnel with Docker.
The frontend-node-modules volume persists Node.js dependencies between container rebuilds, significantly speeding up development and deployment cycles. The app-network creates isolated network communication between your frontend and backend services.
Security Considerations and Limitations
This setup implements several security best practices but has limitations that you should understand for production use. The SSL configuration uses modern TLS protocols and secure cipher suites. The server_tokens off directive hides NGINX version information from potential attackers. Network isolation through Docker networks prevents unauthorized access between services.
However, the authentication approach using hardcoded API keys in headers is basic and suitable primarily for internal service communication. For production applications handling user authentication, you'll want to implement JWT tokens, OAuth flows, or other enterprise-grade authentication mechanisms.
The no-cache headers on assets prioritize development convenience over performance. High-traffic applications should implement proper cache headers with versioning strategies to reduce server load and improve user experience.
Deployment Process
To deploy this setup on your Ubuntu VPS, follow these steps in order. First, ensure your domain's DNS records point to your VPS IP address and that you have Docker and Docker Compose installed.
If you're starting fresh, create your React Vite application in the frontend folder:
# In your project root
pnpm create vite frontend --template react-ts
cd frontend
pnpm install
Generate your initial SSL certificates using certbot before starting the containers:
sudo certbot certonly --standalone -d your-domain.com -d www.your-domain.com
For automatic certificate renewal, reference the detailed setup process in my comprehensive guide: How to Set Up Automatic SSL Certificate Renewal with Certbot in Docker Containers.
Clone your application repository to the VPS and build the containers from the project root:
git clone your-repository
cd your-project
docker compose up --build -d
Verify the deployment by checking container logs and accessing your domain through HTTPS. The setup should serve your React application with proper SSL certificates and functional API routing. If you need to move this stack to a different server later, check out my guide on migrating Docker containers between VPS.
The five failure modes developers hit most often, with exact error strings.
nginx 502 Bad Gateway after deploy
Symptom: Nginx starts and serves the frontend correctly, but API calls return 502 Bad Gateway. The Nginx error log (docker logs <frontend_container>) shows:
[error] 1#1: *1 connect() failed (111: Connection refused) while connecting to upstream, client: ..., upstream: "http://backend:8000/..."
Two causes:
- Service name mismatch: The
proxy_pass http://backend:8000directive uses the Docker Compose service name as the hostname. If your backend service is namedapiincompose.yaml, the directive must beproxy_pass http://api:8000. The names must match exactly. - Backend not ready: If the backend container takes time to initialise, Nginx may start proxying before the backend is accepting connections. Add a health check to your
depends_onin Compose, or implement retry logic in your backend startup.
NGINX won't start: certificates don't exist yet
On a fresh VPS, if you run docker compose up before generating SSL certificates, NGINX will crash immediately with:
nginx: [emerg] BIO_new_file("/etc/letsencrypt/live/example.com/fullchain.pem") failed
(SSL: error:02001002:system library:fopen:No such file or directory)
This is the chicken-and-egg problem: NGINX won't start without a certificate, but Certbot needs NGINX running to answer the ACME HTTP challenge. The solution is to generate your initial certificate using the --standalone flag before starting the containers — which is exactly the approach in the deployment steps above (certbot certonly --standalone). For ongoing automatic renewal inside Docker, see my Certbot auto-renewal guide which covers the dummy certificate pattern.
Permission denied reading /etc/letsencrypt
If NGINX starts but fails to serve HTTPS, check the error log with docker logs <container_name>. A permission problem looks like:
[crit] 1#1: *1 stat() "/etc/letsencrypt/live/example.com/fullchain.pem" failed (13: Permission denied)
Certbot creates certificate files owned by root with restrictive permissions. The fix: ensure you're not running the NGINX master process as a non-root user. The official nginx:stable-alpine image runs the master process as root (which then drops privileges for worker processes) — if you've added user nginx; to your nginx.conf, remove it.
Blank page or 404 on React Router routes
Symptom: the root URL (/) loads correctly, but refreshing /dashboard or any deep route returns NGINX's default 404 page.
This happens because NGINX looks for a file at /dashboard on disk — which doesn't exist. The try_files $uri $uri/ /index.html; line in the location / block is what fixes it, telling NGINX to fall back to index.html and let React Router handle the route. If you see this behaviour, verify that line is present and not overridden by another location block.
Assets returning 404 after Vite build
Symptom: the page loads but appears unstyled, or the browser console shows 404 errors for .js/.css files. The NGINX error log will show:
[error] 1#1: *1 open() "/usr/share/nginx/html/assets/index-abc123.js" failed (2: No such file or directory)
Two common causes:
- Path mismatch: The
COPY --from=builder /app/dist /usr/share/nginx/htmlline in the Dockerfile must match therootdirective innginx.conf. If Vite outputs to a different directory, update both. baseconfig mismatch: Ifvite.config.tssetsbase: '/my-app/', NGINX must serve assets from that prefix — or setbase: './'(relative) to avoid the issue entirely.
Production Deployment Success
This implementation gives you a complete Docker-based setup for React Vite production deployments: multi-stage build with Node 24 LTS and nginx:stable-alpine, correct TLS configuration covering both 1.2 and 1.3, proper cache headers that make Vite's hashed assets immutable while keeping index.html always fresh, and a troubleshooting reference for the five failure modes you're most likely to encounter.
Where to go next:
- SSL auto-renewal inside Docker: Certbot auto-renewal guide
- Skip NGINX entirely with a tunnel: Cloudflare Tunnel + Docker
- Move this stack to a new server later: Migrating Docker containers between VPS
Thanks, Matija


