Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Remix Ubuntu
GUIDEInstall Guides

How to Deploy Remix (React Router v7) on Ubuntu 24.04 VPS: Express Server, PM2, Nginx

24 min read

How to Deploy Remix (React Router v7) on Ubuntu 24.04 VPS: Express Server, PM2, Nginx

Remix is the full-stack web framework that pushed React back toward the web platform: real HTML forms, loaders and actions on the server, nested routes that stream progressively, and no client-side waterfalls. In late 2024, the Remix team merged the project into React Router v7, which now ships the same framework-mode APIs under a unified name. Whether your codebase still says @remix-run/* or has moved to react-router, the deployment story is identical: a Node process that server-renders your routes, a static asset directory under /build/, and an edge — in our case, Nginx — to terminate TLS and cache bundles.

This tutorial walks through a production Remix deployment on a single Ubuntu 24.04 LTS VPS. We will start from a fresh server, install Node.js 20, scaffold a Remix app, swap remix-serve for a custom Express server, run it under PM2 in cluster mode, front it with Nginx, and lock down immutable caching for the fingerprinted /build/ directory. You will finish with a deployment you can update with git pull && pm2 reload remix and that can comfortably serve tens of thousands of requests per day on a small VPS.

Skip the setup? Provision an Ubuntu 24.04 VPS in 60 seconds. Launch a CloudCore Starter and follow this guide from Step 1.

Table of Contents

  • Why Self-Host Remix Instead of Fly.io or Netlify
  • Prerequisites
  • Step 1: Update Ubuntu and Install Build Tools
  • Step 2: Install Node.js 20 LTS
  • Step 3: Create a Dedicated Deploy User
  • Step 4: Scaffold a Remix App
  • Step 5: Build and Smoke-Test with remix-serve
  • Step 6: Add a Custom Express Server
  • Step 7: Loaders, Actions, and Session Storage
  • Step 8: Run Under PM2 in Cluster Mode
  • Step 9: systemd Alternative
  • Step 10: Nginx Reverse Proxy with Immutable /build/ Caching
  • Step 11: TLS with Certbot
  • Step 12: Deploy Updates Safely
  • Troubleshooting
  • FAQ
  • Next Steps
  • Why Self-Host Remix Instead of Fly.io or Netlify

    Remix was born on Fly.io and, for a time, most production deployments ran there or on Netlify, Vercel, and Cloudflare Pages. Those platforms are excellent for getting to production fast. They are also opinionated, usage-metered, and happy to charge you for every function invocation, every GB of egress, and every minute of build time.

    A self-hosted Remix deployment on a VPS is compelling in four ways:

    • Flat cost, no surprises. A CloudCore Starter at EUR 7.99/month serves more traffic than most apps will ever see. Netlify charges by function invocation, build minutes, bandwidth, and concurrent builds; a single viral page can add hundreds of euros to the bill.
    • Full control over the runtime. On a VPS you can install native modules (sharp, canvas, Prisma query engines), run long-lived websocket servers, co-locate Redis or Postgres on the same box, and pin an exact Node version. Serverless platforms sandbox all of that.
    • No cold starts. A PM2-managed Node process stays hot. Every request hits warm V8. Serverless loaders on a cold container can add 300-1500 ms before your code even runs.
    • Data locality. You choose the data center, you see the logs, and your customers' data never transits a third-party function platform. For GDPR-sensitive apps this is a meaningful simplification.
    The trade-off is that you handle OS updates, TLS renewals, and zero-downtime reloads yourself. PM2 cluster mode and a handful of Nginx directives cover all three, and this guide sets them up.

    Cost Comparison: Self-Hosted Remix vs. Managed Platforms

    ScenarioNetlify / VercelFly.io (256MB shared)Self-Hosted on CloudCore Starter
    Base cost (idle)Free tier, then per-seat~$1.94/monthEUR 7.99/month
    1M requests / 100 GB egress~$20 bandwidth + $25 functions~$19 usageIncluded
    Cold startsYes (functions)Small (but present)None
    Custom Node versionLimitedYesYes (any)
    Websockets, long-running jobsPaid add-onsYesYes
    Native modules (sharp, Prisma)Sometimes brokenYesYes
    For any Remix app beyond a weekend project, the VPS wins on both control and total cost of ownership.

    Prerequisites

    Before starting, you need:

    • An Ubuntu 24.04 LTS VPS with at least 1 vCPU, 2 GB RAM, and 20 GB SSD. Remix builds are memory-hungry; below 2 GB the build step will OOM.
    • SSH access as root or a sudo user.
    • A domain name pointed at the VPS public IP (A/AAAA records).
    • Basic Node.js familiarity — you should be comfortable running npm install and reading stack traces.
    Recommended plan: CloudCore Starter
    >
    A CloudCore Starter at EUR 7.99/month gives you 2 vCPU, 4 GB RAM, 50 GB NVMe, and unmetered bandwidth — more than enough for a production Remix app with Postgres or Redis co-located on the same box.

    If Node.js or Nginx are not yet on the server, follow our companion guides first:

    • How to install Node.js on Ubuntu 24.04
    • How to install Nginx on Ubuntu 24.04
    Connect to the server:

    bash
    ssh root@your-server-ip

    Step 1: Update Ubuntu and Install Build Tools

    Start with a clean package index and the toolchain Node native modules need at install time:

    bash
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y build-essential git curl ca-certificates gnupg

    build-essential pulls in gcc, g++, and make, which some npm packages (sharp, bcrypt, better-sqlite3) need to compile native bindings. git handles code deploys. If the kernel was updated, reboot now:

    bash
    sudo reboot

    Step 2: Install Node.js 20 LTS

    Remix officially supports Node 20 and later. Use the NodeSource repository so you can upgrade in place later:

    bash
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt install -y nodejs

    Verify both node and npm:

    bash
    node --version
    npm --version

    Expected output:

    text
    v20.18.1
    10.8.2

    Enable Corepack so Yarn or pnpm work if your project prefers them:

    bash
    sudo corepack enable

    For a deeper look at Node.js installation options including nvm, see our Node.js on Ubuntu guide.

    Step 3: Create a Dedicated Deploy User

    Running Node as root is a security liability. Create a deploy user that owns the application directory:

    bash
    sudo adduser --disabled-password --gecos "" deploy
    sudo usermod -aG sudo deploy
    sudo mkdir -p /var/www/remix-app
    sudo chown -R deploy:deploy /var/www/remix-app

    Copy your SSH key so you can log in as deploy:

    bash
    sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/

    Switch to the new user for the rest of the guide:

    bash
    sudo su - deploy
    cd /var/www/remix-app

    Step 4: Scaffold a Remix App

    You have two equivalent starting points. If you are starting fresh in 2026, use the React Router v7 template (Remix's official successor):

    bash
    npx create-react-router@latest .

    If you are deploying an existing Remix v2 codebase, skip scaffolding and git clone your repo into /var/www/remix-app instead. The deployment mechanics below are identical.

    Choose TypeScript and answer yes to installing dependencies. The scaffold creates roughly this layout:

    text
    /var/www/remix-app
      app/
        entry.server.tsx
        entry.client.tsx
        root.tsx
        routes/
      public/
      package.json
      react-router.config.ts   (or remix.config.js on v2)
      vite.config.ts

    Install any extra production dependencies you know you will need now, so they land in package-lock.json:

    bash
    npm install compression morgan

    We will use both in the Express server later.

    Step 5: Build and Smoke-Test with remix-serve

    Before wiring a custom server, confirm the stock remix-serve (or react-router-serve on v7) flow works. Build the app:

    bash
    npm run build

    Expected output (abbreviated):

    text
    vite v5.4.10 building for production...
    ✓ 128 modules transformed.
    build/client/assets/root-BtZ1k9f2.js     45.21 kB │ gzip: 16.43 kB
    build/client/assets/root-Cv7x8q2a.css     1.03 kB │ gzip:  0.52 kB
    build/server/index.js                    142.88 kB
    ✓ built in 3.21s

    Start the production server on port 3000:

    bash
    npm run start

    From a second SSH session, hit it:

    bash
    curl -I http://127.0.0.1:3000

    You should see HTTP/1.1 200 OK and a text/html content type. Stop the process with Ctrl+C — we will now replace it with a custom Express server so we can add middleware, health checks, and better observability.

    Step 6: Add a Custom Express Server

    A custom server lets you add compression, rate limiting, request logging, and health endpoints without a separate sidecar. Create server.js at the project root:

    javascript
    // server.js
    import express from "express";
    import compression from "compression";
    import morgan from "morgan";
    import { createRequestHandler } from "@react-router/express";
    // On Remix v2, replace the line above with:
    // import { createRequestHandler } from "@remix-run/express";

    const BUILD_DIR = "./build/server/index.js"; const build = await import(BUILD_DIR);

    const app = express();

    // Remove the X-Powered-By header Express adds by default app.disable("x-powered-by");

    // Gzip / brotli negotiation for HTML + loader data app.use(compression());

    // Everything in public/ is long-lived — fingerprinted by Vite app.use( "/build", express.static("build/client/build", { immutable: true, maxAge: "1y", }), );

    // Non-fingerprinted static assets (favicon, robots.txt) — short TTL app.use(express.static("build/client", { maxAge: "1h" }));

    // Access log in combined Apache format app.use(morgan("combined"));

    // Health check for PM2 / uptime monitoring app.get("/healthz", (_req, res) => res.status(200).send("ok"));

    // Hand off everything else to the Remix / React Router request handler app.all( "*", createRequestHandler({ build, mode: process.env.NODE_ENV, }), );

    const port = process.env.PORT ?? 3000; app.listen(port, () => { console.log(Remix server listening on http://127.0.0.1:${port}); });

    For v7, install the Express adapter:

    bash
    npm install @react-router/express

    For v2 Remix projects, install @remix-run/express instead. Fastify users can swap Express for Fastify and @mcansh/remix-fastify — the contract is identical.

    Update package.json scripts to call the new entrypoint:

    json
    {
      "type": "module",
      "scripts": {
        "dev": "react-router dev",
        "build": "react-router build",
        "start": "NODE_ENV=production node server.js"
      }
    }

    Rebuild and start:

    bash
    npm run build
    npm run start
    curl -I http://127.0.0.1:3000/healthz

    You should now see HTTP/1.1 200 OK from the health endpoint and morgan logs scrolling in the terminal.

    Step 7: Loaders, Actions, and Session Storage

    This is not a Remix syntax tutorial, but two production patterns deserve attention because they interact with deployment.

    Loaders stream from the server — keep them fast

    Every route can export a loader that runs on the Node process before HTML is sent:

    typescript
    // app/routes/products.$slug.tsx
    import { json } from "@react-router/node";
    import { db } from "~/db.server";

    export async function loader({ params }) { const product = await db.product.findUnique({ where: { slug: params.slug }, }); if (!product) throw new Response("Not Found", { status: 404 }); return json({ product }, { headers: { "Cache-Control": "public, max-age=60, stale-while-revalidate=600", }, }); }

    Because loaders block the response, any slow query shows up immediately as TTFB. Co-locate your database on the same VPS when possible — loopback latency of ~0.1 ms destroys cross-region latency of 40 ms+.

    Actions handle mutations — use progressive enhancement

    typescript
    export async function action({ request }) {
      const formData = await request.formData();
      await db.product.update({ / ... / });
      return redirect("/products");
    }

    Remix submits the HTML form natively if JavaScript hasn't loaded yet, so your app works during the React hydration window.

    Session storage — choose based on scale

    Remix ships three session adapters. Pick based on your traffic profile:

    typescript
    // app/sessions.server.ts
    import { createCookieSessionStorage } from "@react-router/node";

    export const { getSession, commitSession, destroySession } = createCookieSessionStorage({ cookie: { name: "__session", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", secrets: [process.env.SESSION_SECRET!], maxAge: 60 60 24 * 30, // 30 days }, });

    Options:

    • Cookie sessions (shown above) — session lives in a signed cookie. No server storage needed. Caps at ~4 KB. Perfect for auth state (user id, roles).
    • File sessions (createFileSessionStorage) — sessions stored under /var/www/remix-app/sessions. Works with a single PM2 process, breaks under cluster mode because workers don't share FS locks cleanly. Avoid.
    • Redis / Memcached sessions — use remix-utils or a custom createSessionStorage. Mandatory if you use cluster mode and store >4 KB per user.
    Set your SESSION_SECRET as an environment variable — never commit it:

    bash
    openssl rand -hex 64 > ~/.remix-session-secret
    chmod 600 ~/.remix-session-secret

    We will pass it through PM2's env in the next step.

    Step 8: Run Under PM2 in Cluster Mode

    remix-serve or node server.js run a single Node process. On a 2 vCPU VPS you are wasting half the server. PM2 cluster mode forks one worker per vCPU and load-balances connections across them automatically, using Node's built-in cluster module.

    Install PM2 globally:

    bash
    sudo npm install -g pm2

    Create an ecosystem file at the project root so PM2 configuration lives in git:

    javascript
    // ecosystem.config.cjs
    module.exports = {
      apps: [
        {
          name: "remix",
          script: "./server.js",
          instances: "max",
          exec_mode: "cluster",
          env: {
            NODE_ENV: "production",
            PORT: 3000,
            SESSION_SECRET: process.env.SESSION_SECRET,
          },
          max_memory_restart: "512M",
          merge_logs: true,
          kill_timeout: 5000,
          wait_ready: false,
          autorestart: true,
        },
      ],
    };

    instances: "max" uses every vCPU. max_memory_restart reloads a worker that leaks past 512 MB — a safety net against slow memory growth. kill_timeout gives in-flight requests 5 seconds to drain on reload.

    Start the app:

    bash
    export SESSION_SECRET=$(cat ~/.remix-session-secret)
    pm2 start ecosystem.config.cjs
    pm2 save

    Expected output:

    text
    [PM2] Starting /var/www/remix-app/ecosystem.config.cjs in cluster_mode (2 instances)
    [PM2] Done.
    ┌─────┬───────┬─────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
    │ id  │ name  │ namespace│ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
    ├─────┼───────┼─────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
    │ 0   │ remix │ default │ 1.0.0   │ cluster │ 12345    │ 0s     │ 0    │ online    │ 0%       │ 72.0 MB  │ deploy   │ disabled │
    │ 1   │ remix │ default │ 1.0.0   │ cluster │ 12346    │ 0s     │ 0    │ online    │ 0%       │ 71.8 MB  │ deploy   │ disabled │
    └─────┴───────┴─────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘

    Make PM2 resurrect after reboot:

    bash
    pm2 startup systemd -u deploy --hp /home/deploy
    

    Copy the sudo command PM2 prints and run it

    pm2 save

    Useful PM2 commands:

    bash
    pm2 logs remix --lines 100     # tail logs
    pm2 reload remix               # zero-downtime reload (use this after deploys)
    pm2 restart remix              # hard restart (kills workers immediately)
    pm2 stop remix
    pm2 monit                      # real-time CPU/mem dashboard
    pm2 describe remix             # full process info

    Zero-downtime reloads matter. pm2 reload tells workers to finish in-flight requests, then spawns fresh ones. Users never see a 502.

    Step 9: systemd Alternative

    If you prefer not to install PM2, systemd can manage the Node process directly. This is a good choice when you run a single instance or want to rely on journald for logs.

    Create /etc/systemd/system/remix.service:

    bash
    sudo tee /etc/systemd/system/remix.service > /dev/null <<'EOF'
    [Unit]
    Description=Remix (React Router v7) application
    After=network.target

    [Service] Type=simple User=deploy Group=deploy WorkingDirectory=/var/www/remix-app Environment=NODE_ENV=production Environment=PORT=3000 EnvironmentFile=/etc/remix-app.env ExecStart=/usr/bin/node /var/www/remix-app/server.js Restart=on-failure RestartSec=5 StandardOutput=journal StandardError=journal SyslogIdentifier=remix

    Hardening

    NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=read-only ReadWritePaths=/var/www/remix-app

    [Install] WantedBy=multi-user.target EOF

    Store secrets in /etc/remix-app.env:

    bash
    sudo tee /etc/remix-app.env > /dev/null <<EOF
    SESSION_SECRET=$(cat /home/deploy/.remix-session-secret)
    EOF
    sudo chmod 600 /etc/remix-app.env

    Enable and start the service:

    bash
    sudo systemctl daemon-reload
    sudo systemctl enable --now remix
    sudo systemctl status remix

    systemd gives you journalctl -u remix -f for streaming logs, automatic restart on crash, and resource limits via MemoryMax= / CPUQuota=. The downside versus PM2: no built-in cluster mode. You would spawn workers manually with Node's cluster API inside server.js, or run multiple systemd services on different ports behind Nginx's upstream block.

    Step 10: Nginx Reverse Proxy with Immutable /build/ Caching

    Nginx terminates TLS, compresses responses, and — critically — serves Remix's fingerprinted /build/ assets with aggressive caching so browsers and CDNs never ask for them twice.

    Install Nginx if it isn't present (see our Nginx install guide for the fuller walk-through):

    bash
    sudo apt install -y nginx

    Create the site config:

    bash
    sudo tee /etc/nginx/sites-available/remix-app > /dev/null <<'EOF'
    

    Upstream Node process(es) managed by PM2

    upstream remix_upstream { server 127.0.0.1:3000; keepalive 32; }

    HTTP -> HTTPS redirect

    server { listen 80; listen [::]:80; server_name example.com www.example.com; return 301 https://$host$request_uri; }

    HTTPS virtual host

    server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name example.com www.example.com;

    # TLS certs (Certbot writes these in Step 11) ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off;

    # Security headers add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; add_header Referrer-Policy strict-origin-when-cross-origin always;

    # Allow larger form posts (file uploads via Remix actions) client_max_body_size 25m;

    # Gzip HTML, JSON, JS, CSS (Node already gzips, but Nginx covers static) gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; gzip_vary on;

    # Fingerprinted Remix/React Router bundles — cache forever # Vite emits files like root-BtZ1k9f2.js, so the hash is in the filename location /build/ { alias /var/www/remix-app/build/client/build/; access_log off; expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; }

    # Unhashed static assets in public/ — shorter TTL location /assets/ { alias /var/www/remix-app/build/client/assets/; access_log off; expires 1h; add_header Cache-Control "public, max-age=3600"; try_files $uri =404; }

    # Everything else -> Node location / { proxy_pass http://remix_upstream; 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 Connection ""; proxy_read_timeout 60s; proxy_send_timeout 60s;

    # Allow Remix to stream responses (Suspense boundaries, defer()) proxy_buffering off; proxy_cache off; } } EOF

    Enable the site and reload:

    bash
    sudo ln -sf /etc/nginx/sites-available/remix-app /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t
    sudo systemctl reload nginx

    Why proxy_buffering off? Remix (and React Router v7) can stream HTML with defer() loaders and Suspense boundaries. Nginx's default buffering would hold the entire response before flushing — breaking streaming. Turning it off restores the progressive render.

    Why serve /build/ from Nginx directly instead of through Node? Two reasons. First, Nginx serves static files roughly 10x faster than Node's express.static. Second, it frees the Node workers to handle loaders and actions, which is where the real cost sits. With this config, your Node process almost never sees a request for a JS bundle.

    Step 11: TLS with Certbot

    Free Let's Encrypt certificates, auto-renewed:

    bash
    sudo apt install -y certbot python3-certbot-nginx
    sudo certbot --nginx -d example.com -d www.example.com

    Certbot edits the Nginx config in place, fills in the ssl_certificate paths, and installs a systemd timer that renews certs twice daily. Verify it:

    bash
    sudo systemctl list-timers | grep certbot
    sudo certbot renew --dry-run

    Visit https://example.com in a browser. You should see your Remix app over TLS 1.3 with the lock icon.

    Step 12: Deploy Updates Safely

    A clean update loop for subsequent deploys:

    bash
    cd /var/www/remix-app
    git pull
    npm ci --omit=dev=false       # install including devDeps for the build step
    npm run build
    pm2 reload remix              # zero-downtime swap

    Because Vite emits new content hashes for every change, old bundles stay cached in browsers while new ones are requested with fresh filenames. No cache invalidation needed.

    If a deploy goes wrong, roll back:

    bash
    git reset --hard HEAD~1
    npm ci
    npm run build
    pm2 reload remix

    For truly zero-downtime you can also build in a staging directory and swap with a symlink:

    bash
    ln -sfn /var/www/remix-app-new /var/www/remix-app-current
    pm2 reload remix

    That pattern works well once your CI pipeline grows beyond a git pull.

    Troubleshooting

    ProblemCauseSolution
    502 Bad Gateway from NginxNode process crashed or not listening on 3000pm2 logs remix — look at the stack trace. Check sudo ss -ltnp \</td><td>grep 3000 to confirm binding.
    npm run build OOM killedNode build exceeds VPS RAMAdd swap: sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile. Or upgrade to a larger plan.
    Streaming responses stallNginx buffering onConfirm proxy_buffering off; inside the location / block, then sudo systemctl reload nginx.
    Old CSS/JS in browser after deployContent hash didn't changeForce a full rebuild: rm -rf build node_modules/.vite && npm run build. Check that filenames under build/client/build/ contain a hash.
    EADDRINUSE on port 3000Previous Node process didn't exitpm2 delete remix && pm2 start ecosystem.config.cjs or sudo fuser -k 3000/tcp.
    Sessions lost between requestsCluster mode + file-backed sessionsSwitch to cookie or Redis sessions (see Step 7).
    Loader data cached stale in CDNCache-Control missing on loader responsesExplicitly set headers in the loader: return json(data, { headers: { "Cache-Control": "private, no-cache" } });
    ENOENT: no such file or directory, open 'build/server/index.js'PM2 started before npm run buildRun build first, then pm2 reload remix. Add a prestart npm script to enforce order.

    Viewing logs

    bash
    pm2 logs remix --lines 200       # PM2 app logs
    sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
    sudo journalctl -u remix -f      # systemd alt

    FAQ

    Do I need a custom server, or is remix-serve enough?

    remix-serve (or react-router-serve on v7) is production-ready and fine for most deployments. Swap in a custom Express/Fastify server when you need: custom middleware (rate limiting, request logging, helmet), websockets alongside Remix, shared API routes that bypass the Remix handler, or complex auth that runs before routes. If none of those apply, sticking with remix-serve saves code and complexity.

    How many PM2 instances should I run?

    One per vCPU, which is what instances: "max" does. On a 2 vCPU CloudCore Starter that is 2 workers. Going higher than vCPU count yields nothing: Node is single-threaded per worker, and extra workers just context-switch against each other. Going lower leaves CPU on the table. The only time to pin a specific number is when the app is memory-bound — each worker loads its own copy of the build, so a large app on a small RAM budget may need instances: 1.

    Should I use Remix v2 or migrate to React Router v7?

    New projects: React Router v7 framework mode. It is the canonical path forward, ships the same APIs Remix users know, and is where the team's attention is. Existing Remix v2 apps: there is no rush. The v7 migration is largely renames (@remix-run/* → react-router, @react-router/node, @react-router/express). Follow the official React Router upgrade guide when you have a quiet week.

    How does this compare to deploying Next.js?

    Both ship a Node process that server-renders React and a static bundle directory. Operationally the Ubuntu deployment story is nearly identical — same PM2, same Nginx, same TLS. The architectural differences: Next.js leans heavier on its own build-time and edge abstractions (ISR, middleware, RSC), while Remix/React Router stays closer to the web platform (forms, loaders, standard Fetch). Pick Remix when you want a thinner runtime and native form handling; pick Next.js when you want ISR, the App Router ecosystem, or Vercel-first features. See our Next.js on Ubuntu guide for the parallel walkthrough.

    Can I run Remix behind Cloudflare?

    Yes, and it is recommended. Point your domain at Cloudflare, set DNS to proxied, and keep the Nginx config above on origin. Cloudflare caches your /build/ assets at the edge (the immutable Cache-Control header is obeyed), adds DDoS protection, and terminates TLS a second time. Use "Full (strict)" SSL mode so Cloudflare still verifies your Let's Encrypt cert.

    Where should I put my database?

    For a small-to-medium Remix app, co-locate Postgres or SQLite on the same VPS. Loader queries then run over loopback with sub-millisecond latency, which is the single biggest TTFB win you can get. Upgrade to a separate DB host only when the app outgrows a shared 4 GB of RAM, at which point add a private network between app and DB VPS to avoid public-internet egress costs.

    Next Steps

    • Add a CDN — Point Cloudflare (free plan) at the domain. Remix's immutable bundle caching becomes edge-cached automatically, cutting origin bandwidth by 80 %+.
    • Add monitoring — Install Uptime Kuma on the same VPS and monitor /healthz. Pair with PM2's pm2-logrotate module to keep log files bounded.
    • Add a database — Postgres (managed via postgresql) or SQLite (via better-sqlite3) co-located on the same box. Use Prisma or Drizzle in loaders/actions.
    • Add background jobs — BullMQ on Redis gives you queued work without a second server. Run the worker as a second PM2 process in the same ecosystem.config.cjs.
    • Read the official docs — remix.run/docs and reactrouter.com cover every loader/action/meta/links nuance, including streaming with defer(), optimistic UI with useFetcher, and resource routes for non-HTML endpoints.

    Ready to deploy? A CloudCore Starter at EUR 7.99/month gives you 2 vCPU, 4 GB RAM, and 50 GB NVMe — everything this tutorial assumes, provisioned in under 60 seconds. Ubuntu 24.04 LTS is the default image, so you can paste Step 1 directly into your first SSH session.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket