Deploy Remix to VPS: Complete Guide
Deploy a production-ready Remix application to any Ubuntu VPS using DeployWise — covers remix-serve and Express adapter setup, PM2 process management, Nginx reverse proxy, free SSL certificates, CI/CD with GitHub Actions, and environment variable configuration. No DevOps experience required.
Why deploy Remix to a VPS?
Remix is a full-stack web framework — loaders fetch data on the server, actions handle mutations, and every page is server-rendered by default. Unlike static site generators, Remix requires a Node.js runtime. You can't just upload HTML files to a CDN and call it done.
Managed platforms like Vercel and Fly.io work, but costs scale with traffic. Vercel charges $0.15/GB for bandwidth above 100 GB, and team plans start at $20/month per seat. A $5/month VPS from Hetzner or DigitalOcean gives you unlimited bandwidth, full SSH access, and room to host multiple apps — with DeployWise handling the DevOps.
What you need
| Spec | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 2 vCPU |
| RAM | 512 MB | 1 GB |
| Storage | 20 GB SSD | 40 GB SSD |
| Monthly cost | ~$3/mo | ~$6/mo |
1Remix project setup
If you don't have a Remix project yet, scaffold one with the official CLI:
npx create-remix@latest my-remix-app cd my-remix-app npm install
By default, Remix uses remix-serve as its built-in production server. Your package.json should include these scripts:
{
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"start": "remix-serve ./build/server/index.js"
}
}The start script is what PM2 will use in production. It runs the built server bundle with remix-serve.
2Build your Remix app for production
Compile your Remix app locally to verify the build succeeds before deploying:
npm run build
This generates two output directories:
Add build/ to your .gitignore — it's regenerated during every deployment. Never commit build output.
3Clone on VPS and install dependencies
SSH into your server and set up the project:
# SSH into your VPS ssh root@your-server-ip # Install Node.js 20 LTS via NVM curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc nvm install 20 # Install PM2 globally npm install -g pm2 # Clone your repo git clone https://github.com/your-user/my-remix-app.git cd my-remix-app # Install production dependencies and build npm ci npm run build
Use npm ci instead of npm install — it's faster, deterministic, and respects your lockfile exactly.
4Set up PM2 for remix-serve
PM2 keeps your Remix app running permanently — it auto-restarts on crashes and boots on server reboot. Create an ecosystem.config.js in your project root:
module.exports = {
apps: [
{
name: 'remix-app',
script: 'npx',
args: 'remix-serve ./build/server/index.js',
exec_mode: 'fork',
instances: 1,
env: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: './logs/err.log',
out_file: './logs/out.log',
merge_logs: true,
autorestart: true,
watch: false,
max_memory_restart: '256M',
},
],
};# Start the app pm2 start ecosystem.config.js # Save the process list so PM2 restarts on reboot pm2 save # Enable PM2 startup on boot pm2 startup
Why fork mode? remix-serve is invoked via npx, which doesn't work well with PM2 cluster mode. If you need multiple instances, use the Express adapter (shown below) with exec_mode: 'cluster'.
5Nginx reverse proxy configuration
Nginx sits in front of your Remix app, handling SSL termination, gzip compression, and static asset caching. Create a site config:
upstream remix_app {
server 127.0.0.1:3000;
}
server {
listen 80;
listen [::]:80;
server_name yourdomain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name yourdomain.com;
# SSL certificates (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# Serve static assets directly from Remix build output
location /assets {
alias /var/www/my-remix-app/build/client/assets;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
proxy_pass http://remix_app;
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;
}
}# Save config and test sudo ln -s /etc/nginx/sites-available/remix /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx
The /assets location block serves Remix's fingerprinted static files directly from disk with a 1-year cache header, bypassing Node.js entirely.
6SSL with Certbot
Issue a free SSL certificate from Let's Encrypt using Certbot:
# Install Certbot sudo apt update sudo apt install -y certbot python3-certbot-nginx # Issue certificate (auto-configures Nginx) sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com # Verify auto-renewal sudo certbot renew --dry-run
Set an A record for yourdomain.com pointing to your server IP.
sudo ufw allow 80 && sudo ufw allow 443
Certbot validates domain ownership and installs the certificate into your Nginx config.
Certificates auto-renew every 90 days via a systemd timer. No manual intervention required.
7Environment variables
Remix runs loaders and actions on the server, so process.env is available in those functions. Never expose secrets to the browser.
// In your ecosystem.config.js — add env vars here
env: {
NODE_ENV: 'production',
PORT: 3000,
DATABASE_URL: 'postgresql://user:pass@localhost:5432/mydb',
SESSION_SECRET: 'your-secret-key-here',
}
// In a Remix loader — access via process.env
export async function loader() {
const dbUrl = process.env.DATABASE_URL;
// fetch data from your database...
}process.env values are safe there. Never return secrets directly from a loader — only return the data your component needs.Remix adapters: remix-serve vs Express
Remix supports multiple server runtimes. For VPS deployment, you have two main options:
Built-in — no extra installUse when: You want the simplest setup. No custom middleware needed. Works for 90% of apps.
Best for: Zero config, maintained by Remix team, handles static assets automatically.
npm install express @remix-run/expressUse when: You need custom middleware, WebSockets, health check endpoints, or want PM2 cluster mode.
Best for: Full control over the HTTP server, middleware support, cluster mode compatible.
Recommendation: Start with remix-serve. Only switch to Express if you hit a specific limitation. DeployWise supports both.
Express adapter setup (advanced)
For apps that need custom middleware, WebSockets, or PM2 cluster mode, create a server.mjs file in your project root:
import { createRequestHandler } from '@remix-run/express';
import express from 'express';
import compression from 'compression';
import morgan from 'morgan';
const app = express();
// Gzip compression
app.use(compression());
// HTTP request logging
app.use(morgan('tiny'));
// Serve static assets from build/client with immutable cache
app.use(
'/assets',
express.static('build/client/assets', {
immutable: true,
maxAge: '1y',
})
);
// Serve remaining static files with short cache
app.use(express.static('build/client', { maxAge: '1h' }));
// Remix request handler
app.all(
'*',
createRequestHandler({
build: await import('./build/server/index.js'),
mode: process.env.NODE_ENV,
})
);
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Remix app running on http://localhost:${port}`);
});Update your ecosystem.config.js to use the Express server with cluster mode:
module.exports = {
apps: [
{
name: 'remix-app',
script: './server.mjs',
exec_mode: 'cluster',
instances: 2, // Or 'max' for all CPU cores
env: {
NODE_ENV: 'production',
PORT: 3000,
},
autorestart: true,
max_memory_restart: '256M',
},
],
};Performance tuning
Optimize your production Remix deployment for speed and reliability:
Remix and its dependencies optimize for production mode — smaller bundles, no dev warnings, faster SSR. Set it in PM2 env or before starting the app.
If using the Express adapter, add the compression package. With remix-serve, Nginx handles gzip — just ensure gzip on; is in your Nginx config.
Remix fingerprints all files in /assets with content hashes. Set Cache-Control: public, immutable, max-age=31536000 for these files in Nginx.
HTTP/2 multiplexes requests over a single connection, improving load times for pages with many assets. Add http2 to your listen directive.
Run pm2 monit to watch CPU, memory, and event loop lag in real time. Set max_memory_restart to prevent memory leaks from crashing your server.
GitHub Actions CI/CD for Remix
Automate deployments on every push to main. Create .github/workflows/deploy.yml:
name: Deploy Remix to VPS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/my-remix-app
git pull origin main
npm ci --production
npm run build
pm2 restart remix-appAdd VPS_HOST, VPS_USER, and VPS_SSH_KEY to your GitHub repo secrets under Settings → Secrets and variables → Actions.
Common Remix deployment errors
Problem: React hydration errors appear in the browser console after deploying
Fix: Usually caused by server/client rendering differences. Check for Date/time usage, browser-only APIs (window, document), or conditional rendering based on environment. Ensure NODE_ENV is consistent.
Problem: API calls return 404 or loaders return undefined data in production
Fix: Verify your build completed successfully and the server bundle exists at build/server/index.js. Check PM2 logs: pm2 logs remix-app
Problem: Styles work in dev but the app looks unstyled after deploying
Fix: Ensure Nginx serves /assets from the correct path. Check that build/client/assets/ exists and contains .css files. Verify the /assets location block in your Nginx config.
Problem: App fails to start with Error: listen EADDRINUSE :::3000
Fix: Another process is using port 3000. Find it: lsof -i :3000 — kill the process or change the PORT in your ecosystem.config.js.
Problem: FATAL ERROR: JavaScript heap out of memory during npm run build
Fix: Increase Node memory: NODE_OPTIONS=--max-old-space-size=1024 npm run build — or add swap: sudo fallocate -l 1G /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
Problem: PM2 fails to start with remix-serve not found error
Fix: Ensure @remix-run/serve is in your dependencies (not devDependencies). Run npm ci to install all packages. Or use the full path: npx remix-serve ./build/server/index.js
Deploy Remix with DeployWise (automated)
Skip the manual setup entirely. DeployWise automates every step — from server provisioning to SSL certificates:
Frequently asked questions
Yes. DeployWise is free and open-source. You only pay for the VPS itself, which starts at around $3-5/month from providers like Hetzner, DigitalOcean, or Vultr.
Yes. Unlike static site generators, Remix is a full-stack framework that requires a Node.js runtime to handle server-side loaders, actions, and SSR. This makes a VPS an ideal deployment target.
Use remix-serve for most projects — it's the default built-in server and requires zero configuration. Switch to the Express adapter only if you need custom middleware, WebSockets, or advanced request handling.
Set environment variables in your PM2 ecosystem.config.js under the env object, or configure them in DeployWise project settings. Access them via process.env in loaders and actions. Never expose secrets to the browser.
Hetzner, DigitalOcean, and Vultr are popular choices. A 1 vCPU / 1 GB RAM instance is enough for most Remix apps. Hetzner offers the best price-to-performance in Europe, while DigitalOcean and Vultr have more US data centers.
Use GitHub Actions to SSH into your VPS, pull latest code, install dependencies, build, and restart PM2. Alternatively, enable auto-deploy in DeployWise to trigger deployments on every git push.
Ready to deploy Remix?
Sign in with GitHub, add your VPS, and have your Remix app live in minutes — for free.
Open DeployWise Dashboard