DeployWise
HomeGuidesDeploy Remix to VPS
RemixVPSUbuntuPM2Nginx

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.

14 min read
Updated 2026

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

A VPS running Ubuntu 20.04 or later (DigitalOcean, Hetzner, Vultr, etc.)
SSH access — either password or private key
A GitHub account with your Remix repo
Node.js 18+ (installed on VPS or via DeployWise)
A domain name (optional, but recommended for SSL)
Basic familiarity with the terminal
SpecMinimumRecommended
CPU1 vCPU2 vCPU
RAM512 MB1 GB
Storage20 GB SSD40 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:

1.build/server/ — compiled server-side code (loaders, actions, SSR)
2.build/client/ — static assets (JS bundles, CSS, images)

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
1
Point DNS to your VPS

Set an A record for yourdomain.com pointing to your server IP.

2
Open ports 80 and 443

sudo ufw allow 80 && sudo ufw allow 443

3
Run Certbot

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...
}
Security note: Loaders and actions run server-side only, so 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:

remix-serve (default)
Built-in — no extra install

Use 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.

Express adapter
npm install express @remix-run/express

Use 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:

Always set NODE_ENV=production

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.

Enable compression middleware

If using the Express adapter, add the compression package. With remix-serve, Nginx handles gzip — just ensure gzip on; is in your Nginx config.

Cache static assets aggressively

Remix fingerprints all files in /assets with content hashes. Set Cache-Control: public, immutable, max-age=31536000 for these files in Nginx.

Use HTTP/2 in Nginx

HTTP/2 multiplexes requests over a single connection, improving load times for pages with many assets. Add http2 to your listen directive.

Monitor with PM2

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-app

Add VPS_HOST, VPS_USER, and VPS_SSH_KEY to your GitHub repo secrets under Settings → Secrets and variables → Actions.

Common Remix deployment errors

Hydration mismatch in production

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.

Loader/action not executing

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

CSS not loading in production

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.

Port already in use (EADDRINUSE)

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.

Build fails with heap out of memory

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

remix-serve: command not found

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:

1.Install Node.js via NVM (if not present)
2.Install PM2 globally
3.Clone your GitHub repository
4.Run npm ci to install dependencies
5.Run npm run build to compile Remix
6.Set environment variables from project settings
7.Start/restart the app with PM2
8.Configure Nginx reverse proxy
9.Issue Let's Encrypt SSL certificate (if domain configured)
Server provisioning
Manual: 30 minDeployWise: Seconds
Build + start setup
Manual: 20 minDeployWise: Automatic
PM2 configuration
Manual: 15 minDeployWise: Automatic
Nginx + SSL
Manual: 20 minDeployWise: Automatic
Environment vars
Manual: 10 minDeployWise: Web UI
Monitoring + logs
Manual: Custom scriptsDeployWise: Dashboard

Frequently asked questions

Can I deploy Remix to a VPS for free?

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.

Does Remix need a Node.js server to run?

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.

Should I use remix-serve or the Express adapter?

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.

How do I handle environment variables in Remix on a VPS?

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.

What is the best VPS provider for hosting Remix?

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.

How do I set up CI/CD for Remix on my VPS?

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

Related guides