Running server-side rendered Remix on serverless platforms often hits memory limits, bundle size caps, and painful cold starts when traffic spikes. Putting Remix on a straightforward Ubuntu VPS gives you full control over Node runtimes, persistent background jobs, and raw performance without unpredictable execution timeouts.
Here is my production deployment setup for Remix on a Hostinger VPS using PM2, Nginx, Namecheap DNS, and an edge CDN. The architecture mirrors what we use when we deploy Next.js on Hostinger VPS or run full-stack builds with our guide to deploy SvelteKit on Hostinger VPS.

1. Server Provisioning and Node.js Environment
Spin up a clean Ubuntu 22.04 or 24.04 LTS instance. Before touching the app code, get the base packages updated and install essential build tools.
SSH into your VPS and update your package lists:
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git build-essential nginx ufwRemix runs great on Node 20 LTS. We pull Node directly from the official NodeSource repository according to standard Node.js release distributions, then install PM2 globally to manage our processes.
Run these commands to pull and install Node.js 20 and PM2 globally:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm install -g pm2Check that the binaries installed properly and point to the right paths:
node -v
npm -v
pm2 -v2. Pulling and Building the Remix Project
Never run production Node apps straight out of /root. Create a dedicated folder in /var/www/ and give your deploy user ownership so you don’t run into permission headaches later.
Set up the deployment directory:
sudo mkdir -p /var/www/my-remix-app
sudo chown -R $USER:$USER /var/www/my-remix-app
cd /var/www/my-remix-appClone your repository directly into that directory (or rsync your files over):
git clone https://github.com/your-username/your-remix-repo.git .
npm ci Drop your production environment variables into a .env file in the project root. Make sure to define your port and any database connection strings here:
NODE_ENV=production
PORT=3000
DATABASE_URL="postgresql://remix_user:secret_pass@127.0.0.1:5432/remix_prod"
SESSION_SECRET="super-secure-random-32-character-key"Install dependencies and compile the server handlers and client bundles via Vite as outlined in the official Remix documentation:
npm run build This writes your browser bundles to build/client and the SSR server code to build/server.
3. Configuring PM2 Process Management
While pm2 start npm -- start works for quick testing, an ecosystem file is much better for production. It lets you configure instance counts, memory caps, and log paths cleanly.
Create ecosystem.config.cjs in your app root:
module.exports = { apps: [ { name: "remix-app", script: "./node_modules/@remix-run/serve/dist/cli.js", args: "./build/server/index.js", cwd: "/var/www/my-remix-app", instances: "max", exec_mode: "cluster", env: { NODE_ENV: "production", PORT: 3000 }, max_memory_restart: "400M", error_file: "/var/log/pm2/remix-error.log", out_file: "/var/log/pm2/remix-out.log", time: true } ]
};Make sure your log folder exists, start the process cluster, and freeze the startup hook so PM2 boots back up if your VPS restarts, following the standard PM2 documentation:
sudo mkdir -p /var/log/pm2
sudo chown -R $USER:$USER /var/log/pm2
pm2 start ecosystem.config.cjs
pm2 save
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u $USER --hp $HOME4. Nginx Reverse Proxy and Static Asset Routing
Remix generates fingerprinted client assets inside build/client/assets/. Letting Nginx serve these directly instead of routing every CSS and JS request through Node drops response times from ~40ms to under 5ms and spares your Node event loop.
Create a fresh Nginx config file:
sudo nano /etc/nginx/sites-available/remix-app.confPaste this server block, swapping in your actual domain name:
server { listen 80; listen [::]:80; server_name example.com www.example.com; root /var/www/my-remix-app/build/client; # Immutable fingerprinted assets generated by Remix location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; try_files $uri =404; } # Static public folder files location ~* ^/(favicon.ico|robots.txt|manifest.json)$ { expires 1d; add_header Cache-Control "public, max-age=86400"; access_log off; try_files $uri =404; } # Reverse proxy for SSR routes location / { proxy_pass http://127.0.0.1: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; }
} Symlink the site to sites-enabled, test the syntax, and reload Nginx:
sudo ln -s /etc/nginx/sites-available/remix-app.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx5. Configuring Namecheap DNS Records
Grab your VPS public IPv4 from your Hostinger panel. You will need to map your domain to this IP before requesting an SSL cert.
- Log into Namecheap and go to your Domain List.
- Click Manage next to your domain, then head to Advanced DNS.
- Add these host records under Host Records:
- Type:
A Record| Host:@| Value:YOUR_VPS_IP| TTL: Automatic - Type:
CNAME Record| Host:www| Value:example.com.| TTL: Automatic
If you are weighing apex records against subdomains, check out our walkthrough on configuring root domains in Namecheap DNS: A Record vs CNAME to prevent resolution bugs.
6. Securing with Let’s Encrypt SSL
Once your DNS propagates (usually takes 2 to 10 minutes), grab a free certificate using Certbot following standard Nginx SSL configuration patterns.
Install Certbot and let it patch your Nginx config automatically:
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
sudo certbot --nginx -d example.com -d www.example.comCertbot configures port 80 redirects and drops the SSL cert paths into your Nginx configuration. Check that the renewal systemd timer is active:
sudo systemctl status snap.certbot.renew.service7. Setting Up CDN Edge Caching and Avoiding Loop Errors
Throwing an edge CDN in front of your VPS absorbs random traffic bursts and caches static assets close to your users.
A classic pitfall here: if your CDN connects to your origin over port 80 (HTTP) while Nginx forces an HTTPS redirect, your users get trapped in a redirect loop. We covered the exact fix in our guide on how to fix SSL ERR_TOO_MANY_REDIRECTS on Nginx behind a CDN.
Set your CDN edge-to-origin encryption to Full/Strict over port 443. Then in your Remix loaders, return clear Cache-Control headers for dynamic content you want cached at the edge:
export function headers() { return { "Cache-Control": "public, max-age=60, s-maxage=3600, stale-while-revalidate=86400" };
}8. Gotcha: Vite Base Paths and Static 404s in Remix v2
One bug I ran into frequently early on: shipping a new build while PM2 is running can trigger 404s on chunk JS files for users actively browsing. If your build wipes and replaces build/client/assets/ before PM2 restarts, active clients requesting old hash chunks will fail.
To fix this, make sure your deployment script builds first, swaps assets cleanly, and executes a zero-downtime PM2 reload.
Here is the automated deployment script (deploy.sh) I keep in the project root:
#!/bin/bash
set -e echo "Pulling latest changes..."
git pull origin main echo "Installing dependencies..."
npm ci echo "Building application..."
npm run build echo "Reloading PM2 cluster with zero downtime..."
pm2 reload ecosystem.config.cjs echo "Deployment complete!"Make it executable and run your first zero-downtime deploy:
chmod +x deploy.sh
./deploy.shFrequently Asked Questions
How much RAM does a Remix app require on a VPS?
A typical Remix app on Node 20 with PM2 consumes around 120MB to 180MB of RAM per worker. A basic 1GB or 2GB VPS plan comfortably runs 2 cluster workers alongside Nginx and system services.
Can I run multiple Remix or Node apps on the same VPS?
Yes. Give each app a unique local port (like 3000, 3001) in its PM2 config, then create distinct Nginx server blocks routing each domain to its matching local port.
Why serve /assets/ through Nginx instead of Remix directly?
Nginx serves files directly off the disk via kernel-level sendfile calls without touching Node. This keeps your Node event loop free for loader queries, dynamic rendering, and action handlers.
What should I do if PM2 restarts continuously?
Check the crash stack with pm2 logs remix-app --err --lines 50. In 90% of cases, it is either an unhandled missing variable in your .env file or a port collision on port 3000.

