← Guides
Deployment Guides

How to Deploy TanStack Start to a VPS

TanStack Start is a new full-stack React framework built on Vite and Nitro. This guide covers deploying it to a Linux VPS with Node.js, including environment variables, the output structure, and reverse proxy setup.

ThomasThomas·2026-05-27

TanStack Start is one of the newest full-stack React frameworks and deployment guides for it are scarce. It is built on top of Vite and uses Nitro as its server layer, which means the deployment model is similar to Nuxt or Analog — a single node .output/server/index.mjs process serves both the API and the SSR'd frontend.

This guide covers deploying TanStack Start to a Linux VPS. It assumes you are using the Node.js adapter, which is the default for self-hosted deployments.

How TanStack Start builds work

When you run vite build, TanStack Start produces a .output/ directory with two subdirectories:

  • .output/server/ — the SSR server entry point (index.mjs) and server-side code
  • .output/public/ — static assets (CSS, JS chunks, images) to be served by your reverse proxy

The server entry point is a self-contained Node.js script. It does not need node_modules at runtime because Nitro bundles all dependencies into the output. This makes deployment straightforward — copy .output/ to the server and run it.

Choosing a server

TanStack Start apps are typically full-stack with server functions and API routes, so you want a real VPS rather than a static hosting platform.

Hetzner Cloud is hard to beat on price-to-performance. A CX22 (2 vCPU, 4 GB RAM) at around 4 EUR/month is more than enough for a production TanStack Start app. Their ARM-based CAX instances are even cheaper and perform well for Node.js workloads.

DigitalOcean Droplets are a solid alternative with a more polished managed services ecosystem. If you want managed Postgres rather than running your own, their $12/month Droplet paired with a managed database is a clean setup.

Railway is worth considering if you want to skip server administration entirely. You push your repository, Railway detects the Vite build, and it runs the output for you. More expensive per unit of compute than a raw VPS, but zero ops overhead.

Server setup

Install Node.js 22:

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

Install PM2:

npm install -g pm2

Environment variables

TanStack Start uses Vite's environment variable system with a strict split between server and client:

  • Variables without a prefix (e.g. DATABASE_URL) are server-only. Access them with process.env.DATABASE_URL inside server functions and API routes.
  • Variables with a VITE_ prefix (e.g. VITE_PUBLIC_API_URL) are inlined into the client bundle at build time. Never put secrets in VITE_ variables — they will be visible to anyone who loads your page.

Create a .env file for production. TanStack Start does not have mandatory framework-level secrets, but your app will have its own:

NODE_ENV=production

# Server-only secrets — never use VITE_ prefix for these
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
SESSION_SECRET=your_long_random_secret

# Public values — safe to expose to the browser
VITE_PUBLIC_APP_NAME=My App

One important gotcha: VITE_-prefixed variables are inlined at build time, not at runtime. If you change a VITE_ variable, you must rebuild the app for the change to take effect. Plain environment variables without the prefix are read at runtime and can be changed without rebuilding.

Building the app

Install dependencies and build:

npm ci
npm run build

This runs vite build and produces .output/. The build step bundles everything — you do not need to copy node_modules to the server.

Deploying to the server

Copy the output to your server:

rsync -avz --delete .output/ user@yourserver.com:/var/www/myapp/

Also copy your .env file:

scp .env user@yourserver.com:/var/www/myapp/.env

The .output/ directory is self-contained. It does not need node_modules, package.json, or any other project files alongside it.

Starting the app with PM2

pm2 start /var/www/myapp/server/index.mjs --name myapp
pm2 save
pm2 startup

Run the command pm2 startup prints to register PM2 with systemd.

Verify it is running:

pm2 logs myapp

You should see output like:

Nitro server listening on http://0.0.0.0:3000

The default port is 3000. You can change it with the PORT environment variable.

Reverse proxy with Caddy

Caddy is the simplest option for automatic HTTPS. Install it:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy

Edit /etc/caddy/Caddyfile:

yourdomain.com {
    # Serve static assets directly — faster than going through Node
    handle /assets/* {
        root * /var/www/myapp/public
        file_server
    }

    # Everything else goes to the Node server
    handle {
        reverse_proxy localhost:3000
    }
}

Reload Caddy:

sudo systemctl reload caddy

Serving the static assets directly from Caddy rather than through Node is worth doing. TanStack Start generates hashed asset filenames, so they can be cached aggressively.

Deploying updates

#!/bin/bash
set -e

echo "Building..."
npm ci
npm run build

echo "Uploading..."
rsync -avz --delete .output/ user@yourserver.com:/var/www/myapp/

echo "Restarting..."
ssh user@yourserver.com "pm2 reload myapp"

echo "Done."

Common issues

"Cannot find module" on startup — the server entry point cannot find a bundled dependency. This usually means the build did not complete cleanly. Delete .output/ and rebuild.

Environment variables are undefined on the client — you tried to read a plain process.env.DATABASE_URL in a client component. Server-only variables are not available in client code. Move that logic to a server function.

VITE_ variable is not updated after changing .envVITE_ variables are baked in at build time. You need to rebuild and redeploy for the change to take effect.

Port 3000 is already in use — set PORT=3001 (or any free port) in your .env file and update your Caddy config to match.

Static assets 404 in production — check that your Caddy root path matches where .output/public/ was copied. The assets subdirectory inside .output/public/ is where the hashed JS and CSS files live.


TanStack Start is still young and the deployment tooling will get more polished over time. For now, the Nitro output model makes self-hosting clean once you know the steps. If you want to skip the setup entirely, Jetpacked can detect TanStack Start in your repository and deploy it without any configuration from you.

Deploy your app in minutes

Jetpacked handles Docker, HTTPS, databases, and deployments — so you can focus on building.

Launch your app free