Deploy React Router v7 from GitHub: A BYO-Server Production Guide
If you've spent any time on reactrouter.com/start/framework/deploying lately, you'll have noticed the page lists exactly two kinds of deploy targets. The first is the Docker triad — the default, custom-server, and Postgres templates, each pointed at a containerised platform like AWS ECS, Cloud Run, Azure Container Apps, Digital Ocean App Platform, Fly.io, or Railway. The second is the managed-host rows: Vercel, Cloudflare Workers, Netlify, EdgeOne Pages. Each row links out to a guide on the provider's own site. The pattern is consistent and the coverage is good, but it has a missing column: the "I run my own Ubuntu box and I'd like Git push to deploy it" path. That's the gap this guide fills.
A quick note for the search query that brought a chunk of readers here: Remix has merged into React Router v7. The Remix framework documentation under remix.run/docs/en/main/* now 302-redirects to the legacy v2 site, and reactrouter.com is the canonical surface for new framework work. If you were searching for "deploy Remix from GitHub" or "deploy Remix to VPS," React Router v7 is the merged successor, and what follows is the BYO-server walkthrough for the Node templates that came over from Remix. The rest of the guide will refer to the framework as React Router v7 throughout.
The audience is specific. You're using one of the three Node templates that ship in remix-run/react-router-templates — default, node-custom-server, or node-postgres. You're not using Cloudflare Workers, Vercel, Netlify, or EdgeOne Pages; those have managed guides linked from the upstream docs already. You have a Linux server you operate yourself — Hetzner, OVH, Linode, DigitalOcean, an EC2 instance you SSH into directly, a bare-metal box in a closet — and you'd like Git push, build, transfer, restart, with atomic releases and one-click rollback. You're comfortable with Node, SSH, and a process manager. What you don't want to do is write the SSH and rsync orchestration by hand, mint your own release-directory rotation, or hand-roll a CI workflow for it.
By the end, you'll have a React Router v7 app that builds in DeployHQ's runners, ships the compiled build/ directory plus package.json and package-lock.json over SSH to your server, installs production dependencies in place, reloads the Node process under PM2 or systemd, and rolls back to any previous release with one click from the dashboard. The same project covers production and staging via branch-to-server mapping, with per-environment config variables for things like the database URL and session secrets.
Prerequisites
You need five things before starting.
A React Router v7 project from one of the Node templates. The templates live at github.com/remix-run/react-router-templates. The default template gives you react-router-serve as the production runner — the simplest case. The node-custom-server template uses Express, which is the right choice when you need middleware, custom routes outside the React Router request tree, or a hand-rolled HTTP layer. The node-postgres template is the Express setup plus Drizzle ORM and a postgres driver. Whichever you started from, the deploy shape in this guide works without modification.
A Git repository. The repo can live on GitHub, GitLab, or Bitbucket — DeployHQ supports all three via OAuth, and private repositories work without further token wrangling. If you'd rather scope a read-only deploy key per project, that option is available in the project's integration settings too. Pick whichever fits your team's existing access patterns.
A Linux server you control with SSH access. Ubuntu LTS or Debian stable are the typical pick; anything with sshd and a working package manager works. You'll want a non-root user (commonly deploy) with sudo access for the initial setup and a public-key-only SSH configuration. Disk space depends on how many release directories you keep; for a typical React Router app, ten releases land in single-digit gigabytes.
Node.js 20 or later on the server. React Router v7's published minimum is node >= 20.0.0 (from the react-router package's engines.node field), and the rest of the toolchain assumes a current LTS. If you've still got Node 18 on the box, upgrade before continuing — the react-router-serve binary won't start under it, and the Express templates pull in dependencies that assume current Node APIs. The DeployHQ build runners support Node 20 and 22; pick the same major your server runs and pin it explicitly in both places, which you'll do later.
A DeployHQ account. The free plan covers a single project, which is enough to wire up the pipeline end-to-end against your real repo before deciding whether to upgrade. Sign-up is at the URL linked from the CTA later in this guide.
One thing the prerequisite list deliberately doesn't include: a containerisation strategy. The Docker rows on the React Router deploying page exist for the containerised-control-plane case (Cloud Run, ECS, Fly). This guide is the non-Docker path to the same outcome — your server runs Node directly, behind your existing reverse proxy, under your existing process manager. If you want a container-shaped deploy, the existing Docker-template rows on the upstream page are the right starting point. If you want to push to main and have your VPS rebuild and reload itself, keep reading.
Understand the React Router v7 build output
Before configuring the pipeline, it pays to understand exactly what npm run build produces and what the server needs to run it. The shape of the output dictates what DeployHQ has to transfer and what your post-deploy hook has to do.
In all three Node templates, the build script is the same one-liner:
"build": "react-router build"
That's the entire build script in default/package.json, node-custom-server/package.json, and node-postgres/package.json at v7.16.0. Running npm run build invokes the React Router CLI, which compiles your app with Vite under the hood and writes the output into the build/ directory at the project root.
The directory the build produces has the same structure across all three templates:
build/
├── client/ # Static assets — hashed JS, CSS, images. Served as static files.
└── server/
└── index.js # The SSR bundle. Loaded by the production server at runtime.
The default template's own README documents this exact tree under its "DIY Deployment" section, which is the most upstream confirmation you can get short of running the build yourself. If you want to confirm before publishing, the recipe is short: npx create-react-router@latest --template remix-run/react-router-templates/default scratch-app && cd scratch-app && npm install && npm run build && ls build/.
The interesting half is how each template starts the server in production:
Default template. The production start script is
react-router-serve ./build/server/index.js.react-router-serveis a thin server provided by@react-router/serve, which the default template lists independencies. It's the simplest case: no Express, no custom middleware, just the bundled server module run by a packaged binary. The binary lands insidenode_modules/.bin/afternpm install, which means you need a runtime dependency tree on the server to start the app — the post-deploy hook installs production dependencies in place to give you exactly that.node-custom-servertemplate. The production start script isnode server.js. Theserver.jsfile is at the project root and is part of the source tree, not the build output. It's a small Express server that loads the React Router request handler from./build/server/index.jsat startup and mountsexpress.static("build/client/assets", { immutable: true, maxAge: "1y" })for the hashed client assets. The relevant takeaway for deployment: this server expects to be started from the project root, withbuild/populated andnode_modules/installed alongside it. If you start it from another directory, the relative./build/server/index.jsimport will fail.node-postgrestemplate. The production start script in v7.16.0 is literallynode --conditions development server.js. That--conditions developmentflag is present in the upstreamstartscript as shipped, and it's the kind of thing that tends to be tightened in a point release — but for now, deploying this template as-is will pick up development-flavoured exports for any package that uses Node'sexportsconditions. In practice that's fine for most cases because the production code path doesn't switch on those conditions, but if you're shipping the Postgres template to production, the cleanest thing to do is override the start command in your process manager to drop the flag (node server.js) and rely onNODE_ENV=productionfor the rest of the runtime behaviour. The rest of the template structure — Express server, build output, drizzle config,database/directory — is the same asnode-custom-serverplus a Drizzle ORM layer.
There's one other useful fact to pin down here, because it shapes the deployment subdirectory setting in DeployHQ later: the source layout at the repository root looks roughly like this for the Express-based templates:
app/ # Source — routes, components, root.tsx
build/ # Build output (gitignored)
database/ # Postgres template only — Drizzle schema + context
public/ # Static assets copied through to build/client/
server.js # node-custom-server + node-postgres entry
server/ # node-custom-server + node-postgres — server-side TS that imports into server.js
drizzle.config.ts # Postgres template only
drizzle/ # Postgres template only — generated migrations
react-router.config.ts # React Router config
package.json
package-lock.json
tsconfig.json
vite.config.ts
The build/ directory is the only thing that doesn't exist until you've run the build, which is why it's gitignored and rebuilt on every deploy. The other files at the root are either source you need on the server at runtime (server.js, server/, package.json, package-lock.json) or build-time-only (tsconfig.json, vite.config.ts, the app/ directory, the drizzle.config.ts). The deployment subdirectory in DeployHQ will be the project root, because the server needs build/, server.js, package.json, and package-lock.json to coexist in the same directory at runtime. There's no way to ship "just the build output" the way you might for a pure static site — a Node SSR app needs the runtime entry, the dependency manifest, and the bundled server in the same place.
The shape of the runtime is what dictates the deployment shape. Keep that picture in mind through the next two sections.
Provision the target server
The server side of the pipeline is the part DeployHQ doesn't do for you. You need a Linux box with Node, a process manager, a reverse proxy, and an SSH user that the deploy can authenticate as. This section walks through a minimal setup that's been kept deliberately distribution-agnostic — the commands assume Ubuntu LTS or Debian stable, but adapt cleanly to anything with sshd and a package manager.
Create a deploy user. Don't deploy as root, even on a single-tenant VPS — the blast radius isn't worth saving the keystroke. Create a dedicated user (call it deploy), disable password login, and add their public key to ~/.ssh/authorized_keys:
sudo adduser --disabled-password deploy
sudo mkdir -p /home/deploy/.ssh
sudo touch /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
You'll add DeployHQ's deploy key into that file shortly. For now, leave the user shape correct and move on.
Install Node.js 20 or newer. Use whichever method matches your distribution's conventions. NodeSource maintains apt/yum repositories that track recent Node releases; nvm is the alternative if you'd rather manage versions per-user. The pick matters less than two things: that the major version matches what DeployHQ's build runner uses (you'll match this in the next section), and that the version is at or above v7's >=20.0.0 floor. A canonical NodeSource install for Node 22 on Ubuntu looks like this:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
Verify with node --version and npm --version. The deploy user needs to be able to run both without sudo, which is the default after a system-wide install.
Pick an application directory. A typical convention is /var/www/<appname>/ for the app's tree, with current/ as the symlink target the web server reads from. DeployHQ's atomic release pattern (more on this later) writes each release into a sibling releases/<timestamp>/ directory and switches current to point at the latest. Create the parent directory and chown it to the deploy user:
sudo mkdir -p /var/www/myapp
sudo chown -R deploy:deploy /var/www/myapp
You don't need to pre-create current/ or releases/; DeployHQ creates them on the first deploy.
Set up the reverse proxy. A Node app behind a reverse proxy is the standard production shape because the reverse proxy handles TLS termination, HTTP/2, gzip and brotli, and the static-file fast path for assets that don't need to traverse the Node process. Nginx is the most common choice; Caddy gets you automatic TLS via Let's Encrypt with less config. A minimal Nginx server block in front of a React Router app listening on localhost:3000 looks like this:
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
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_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;
}
}
The Caddy equivalent is shorter; if you prefer Caddy, a four-line site block (reverse_proxy 127.0.0.1:3000) handles the same shape plus automatic certificate provisioning.
Open the firewall for HTTPS only. If you're running ufw, the canonical pair is sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full'. The Node process listens on a loopback port, not a public one, so it doesn't need its own firewall rule.
Install a process manager. PM2 is the default recommendation in this guide — sudo npm install -g pm2 on the server, then pm2 startup (which prints a systemctl enable command for you to run) and pm2 save after your first deploy to make the process survive reboots. If you'd rather use systemd directly, skip the PM2 install; the systemd unit file in the post-deploy section below assumes systemd is already managing the service.
A note on shared state across releases. Some files don't belong inside a single release directory because they outlive any one release: a runtime .env file, log directories you'd like to keep around across deploys, uploaded user assets if your app accepts file uploads, anything else that's data rather than code. The convention is a shared/ directory at the same level as releases/ and current:
/var/www/myapp/
├── current -> releases/<latest>/
├── releases/
└── shared/
├── myapp.env
└── uploads/
If you're using systemd, the unit file points at /var/www/myapp/shared/myapp.env for its EnvironmentFile=. If your app writes uploads, symlink /var/www/myapp/current/uploads to /var/www/myapp/shared/uploads in the post-deploy hook so writes go to the shared directory rather than into the release that's about to be rotated away. This is the same pattern Capistrano and similar Ruby-flavoured deploy tools popularised; the shape is well-trodden because the failure mode it prevents (uploads disappearing on next deploy) is unforgiving.
What you've built so far is the same shape as deploying any other Node app — a Linux user, a runtime, a reverse proxy, a TLS certificate, a process manager. What's about to differ is the build, transfer, and release layer: instead of ssh-ing in and git pull && npm install && npm run build && pm2 reload, you'll let DeployHQ do all of that on every push.
Create the DeployHQ project
A DeployHQ project wraps one repository and its build configuration; servers are attached underneath. You'll create one project per React Router v7 app — staging and production are separate servers on the same project, mapped to different branches, which the multi-environment section covers later.
Open the DeployHQ dashboard and create a new project. The wizard asks for two things in order: the repository and the deploy targets. Here's the version that matches what you've set up so far.
Pick the Git provider and connect over OAuth. DeployHQ supports deploying from GitHub to a server you control, deploying from GitLab to your infrastructure, and deploying from Bitbucket Cloud. Authentication goes through OAuth, which means private repositories work without minting a deploy key by hand. If your org disallows organisation-wide OAuth grants, the project settings let you switch to a per-repo read-only deploy key after the fact; DeployHQ never needs write access to your source.
Select the repository for your React Router v7 app and pick a branch. main is the standard pick for production; you'll wire staging to a separate server later. The project name is worth setting to something memorable — myapp-prod rather than just myapp if you anticipate having a separate staging project, or just the domain (app.example.com) if you'll run staging as a server on the same project. There's no rename penalty if you change your mind.
Once DeployHQ has cloned your repo into its build infrastructure, you'll land on the project dashboard with an empty deploy history and a configuration tree on the left. You're about to add a build pipeline and a server. Both pieces need to be in place before the first deploy can run.
It's worth taking a beat here to confirm where DeployHQ does and doesn't reach. The full clone happens inside DeployHQ's build environment on every deploy. It does not happen on your destination server. Nothing in your repository is pushed to production; only the files in the deployment subdirectory you'll configure get transferred. For a React Router v7 app that's the project root after the build runs, which contains build/, package.json, package-lock.json, and (for the Express templates) server.js plus the server/ directory. Your source files (app/, tsconfig.json, vite.config.ts, your .git directory) never leave the build runner.
Configure the build pipeline
The build pipeline is where DeployHQ becomes useful, and where most of the per-project decisions live. Three things to get right: the install-and-build commands, the Node version, and the build-time environment variables. We'll cover them in that order, then add the post-deploy SSH hook in the next section.
Install and build commands
Add a single build command to the project's build pipeline:
npm ci && npm run build
The command runs inside DeployHQ's isolated build environment, in the cloned repository root. npm ci installs strictly from package-lock.json, refuses to update the lockfile, and fails fast on any mismatch — exactly what you want in CI. npm run build then runs the build script defined in package.json, which expands to react-router build for all three Node templates. Build artefacts land in the build/ directory at the repository root.
If your project uses a different package manager, the analogues are:
- pnpm:
pnpm install --frozen-lockfile && pnpm build - yarn 1:
yarn install --frozen-lockfile && yarn build - yarn 2+:
yarn install --immutable && yarn build
The strict-install flag matters in all four cases. A pipeline that runs npm install (without ci) or yarn install (without a strict flag) will silently update the lockfile against the latest semver-compatible versions, which is how "works on my machine" becomes "broken in production" on a Friday afternoon. Lockfile-only installs catch dependency drift at build time, not at runtime.
There's no need to add a separate "production" install step here; the build needs the full dependency tree, including devDependencies like @react-router/dev, Vite, TypeScript, and the Tailwind toolchain. The post-deploy SSH hook on your server does the production-only install once the artefacts have landed, which is the standard pattern for separating build-time and runtime concerns.
Pin the Node version
DeployHQ lets you select a Node version per project from the build-environment configuration. Pick a major (20 or 22) and stick with it. Match the major to what your server runs; mismatches between the build runner and the target server can surface as native-module load errors at runtime, and there's no good reason to volunteer for that class of bug.
Pin the version in two places so the choice is explicit:
- In the repository. Add an
.nvmrcfile at the project root containing a single line likev22(or a more specific version likev22.10.0if you've got a reason to be exact). Anyone usingnvmlocally picks up the right version automatically. Theengines.nodefield inpackage.jsonis the alternative or the supplement — both are advisory, but they make the intent visible to future readers. - In the DeployHQ project's build environment configuration. Select the matching major. DeployHQ won't read your
.nvmrcto choose a Node version on its build runners; you set it explicitly on the project.
If you bump the local Node version because a transitive dependency requires it, bump the DeployHQ project's Node version at the same time and check the version on the server. The three should stay aligned.
Build-time vs runtime environment variables
This is the distinction that most often goes sideways on the first deploy: what DeployHQ injects at build time isn't the same as what your app reads at runtime.
The build environment has whatever variables you configure on the DeployHQ project or server. For React Router v7, the only build-time variable you typically need is NODE_ENV=production, which most of the toolchain reads to switch into production-optimised paths (smaller bundles, fewer warnings, no source maps in client output unless you explicitly enable them). DeployHQ's build pipeline lets you set this on the project and inherit it to all servers, or override it per-server if you have a reason to.
The runtime environment is where everything else lives: the database URL (for the Postgres template), session secrets, third-party API keys, the public site URL, anything your app reads from process.env at request time. These belong on the target server, not in the build artefacts. There are three ways to provide them, and you can mix them as needed:
- A
.envfile at the deployment path that you maintain out-of-band. Drop it on the server once, set the file mode to0600owned by the deploy user, and load it from your process manager. This is the simplest pattern for small teams. Environment=directives in a systemd unit file. Cleanest for systemd-managed deploys because the variables are visible insystemctl showand don't require a file the application has to read itself.- A PM2 ecosystem file with
env:blocks. PM2 picks these up at process start and exports them into the Node process. You can keep different env blocks per environment in the same file (env_production,env_staging) if you want one ecosystem file to cover both.
Whichever you pick, do not commit runtime secrets to the repository, even encrypted. They live on the server, and they cycle independently of your build pipeline.
A note specific to the Postgres template: the .env.example file shipped in node-postgres contains exactly two keys — NODE_ENV and DATABASE_URL. The Drizzle config reads DATABASE_URL from process.env at startup and throws if it's missing. Whichever runtime-env strategy you pick, both keys need to be set on the server before the process can start.
What to transfer
DeployHQ's default behaviour is to transfer the entire repository root to the destination. For a React Router v7 app that's almost right, but you want the post-build state of the repository root, which includes the freshly-built build/ directory but excludes things like node_modules/, .git/, and source files you don't need at runtime.
The way to do this is two-pronged: set the deployment subdirectory to the repository root (which is the default), and use the project's exclude-files configuration to skip things you don't want to ship. A reasonable exclude set for a React Router v7 deploy looks like:
.git/
.github/
node_modules/
app/
public/
react-router.config.ts
vite.config.ts
tsconfig*.json
.env.example
.dockerignore
Dockerfile
README.md
The app/ directory contains TypeScript and JSX sources that aren't needed at runtime — the SSR bundle in build/server/index.js already imports the compiled output. The public/ directory's contents are typically copied through to build/client/ during the build, so shipping public/ separately just duplicates files. Vite, TypeScript, and Tailwind configs are all build-time-only. The node_modules/ exclusion is the critical one: shipping a freshly-installed dependency tree across the wire on every deploy is slow, brittle, and pointless because the post-deploy hook will rebuild it in place using the platform-native binaries the target server actually needs.
For the Express templates, server.js and the server/ directory do need to ship — they're runtime entry points. Don't add them to the exclude list.
Build caching
DeployHQ's build environment caches what you tell it to cache between builds. The pragmatic thing to cache is the npm cache directory (~/.npm) so that npm ci can pull from a warm local store instead of re-downloading the whole dependency tree on every build. Cache the package manager's cache; don't cache node_modules/ directly — that path tends to confuse package managers when versions change, and the install commands above are fast enough off the cache that the difference isn't worth the brittleness.
Configure the cache directory in the build pipeline's environment settings.
DeployHQ runs the build, ships the output over SSH, and reloads your Node process on every push to main. The free plan covers one project — enough to deploy your first React Router v7 app to a server you control. Sign up for a free DeployHQ account and connect your repo to wire the rest of this guide against your real project.
Post-deploy SSH hook: install, restart, atomic release
The build pipeline has produced an artefact tree in DeployHQ's runner, and DeployHQ has transferred it to a release directory on your server. There are two things left to do: install production dependencies in place, and reload the Node process so it picks up the new release. Both happen via a post-deploy SSH hook configured on the project.
Open the server's "SSH commands" configuration in the DeployHQ project (the exact name in the UI is "SSH commands" under the server settings). The hook is a shell script that runs on the destination server, as the deploy user, after the file transfer completes. DeployHQ exposes the path to the freshly-deployed release via built-in template variables — you'll see %path%, %endrev%, and %branch% in the docs, where %path% resolves to the path the deploy was transferred to.
The body of the hook is short. For the default template:
cd %path%
npm ci --omit=dev
pm2 reload myapp || pm2 start "npx react-router-serve ./build/server/index.js" --name myapp
For the node-custom-server template, the start command swaps out:
cd %path%
npm ci --omit=dev
pm2 reload myapp || pm2 start ./server.js --name myapp
For the node-postgres template, add the migration step before the restart:
cd %path%
npm ci --omit=dev
npm run db:migrate
pm2 reload myapp || pm2 start ./server.js --name myapp
A few things to call out about what these commands actually do.
npm ci --omit=dev is the production-install variant: it installs strictly from package-lock.json and skips devDependencies. For a default-template deploy, this still pulls down @react-router/serve and @react-router/node, because they're in dependencies (not devDependencies). For the Express templates, it pulls in express, compression, morgan, and the React Router runtime. node_modules/ is rebuilt from scratch on every deploy, which is what you want — there's no cross-deploy contamination, and the binaries are compiled against the target server's libc and Node version.
pm2 reload myapp || pm2 start ... handles both the steady state and the first-deploy case. If a PM2 process called myapp is already running, reload performs a zero-downtime restart by spinning up a new instance and shifting traffic over before stopping the old one. If reload exits non-zero because the process doesn't exist yet, the || branch starts it fresh. This pattern works the same way for react-router-serve and node ./server.js.
For the Postgres template, npm run db:migrate runs dotenv -- drizzle-kit migrate, which applies any new migrations from the drizzle/ directory to the database configured in DATABASE_URL. That migration runs after the install completes (so drizzle-kit is present in node_modules/.bin/) and before the process reload (so the schema is in the expected shape when the new process starts handling requests). The post-deploy hook does not roll migrations back — that's covered in the rollback section.
PM2 vs systemd
PM2 is the default recommendation in this guide because it's familiar to most Node developers and its reload semantics map cleanly to the post-deploy hook. systemd is the alternative, and it's the right pick if you'd rather not add a runtime dependency, prefer the rest of the system's services to come up the same way, or already use systemd for everything else on the box.
The PM2 path is what's shown above. A minimal ecosystem file (ecosystem.config.cjs at the project root) is optional but makes the per-environment configuration more explicit:
module.exports = {
apps: [
{
name: "myapp",
script: "npx",
args: "react-router-serve ./build/server/index.js",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 3000,
},
},
],
};
If you commit the ecosystem file, the post-deploy hook simplifies to:
cd %path%
npm ci --omit=dev
pm2 reload ecosystem.config.cjs --update-env
The --update-env flag re-reads the env block on reload, which is what you want when your environment variables change between deploys (different config-variables block on staging vs production, for instance).
For systemd, the corresponding unit file lives at /etc/systemd/system/myapp.service:
[Unit]
Description=My React Router v7 app
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp/current
EnvironmentFile=/var/www/myapp/shared/myapp.env
ExecStart=/usr/bin/node_modules/.bin/react-router-serve ./build/server/index.js
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
WorkingDirectory=/var/www/myapp/current points at the symlink DeployHQ updates on every release. EnvironmentFile= loads runtime secrets from a file you maintain out-of-band — keep it under /var/www/myapp/shared/ (or wherever you've decided to keep cross-release state), mode 0600, owned by the deploy user. Restart=always is what gives you crash recovery; RestartSec=2 gives the box a moment to settle on the rare crash-loop.
For systemd, the post-deploy hook becomes:
cd %path%
npm ci --omit=dev
sudo /bin/systemctl restart myapp.service
The sudo is the catch. The deploy user needs passwordless sudo for that exact command and nothing else. The minimal sudoers snippet is:
deploy ALL=(root) NOPASSWD: /bin/systemctl restart myapp.service
Drop that into a file under /etc/sudoers.d/ (with visudo to validate it) and the post-deploy hook can issue the restart without prompting.
systemctl restart is a full process replacement, which means a brief window where the process is down — typically under a second on a healthy box. If you need true zero-downtime restarts on systemd, the pattern is socket activation: have systemd own the listening socket and pass it to the Node process at start, which lets you swap the process without dropping connections. That's more setup than most teams need; PM2's reload is simpler and gets you to the same place for the common case.
Atomic releases
What makes the restart safe to run on every deploy is the atomic release pattern DeployHQ uses on SSH targets. Instead of overwriting the contents of /var/www/myapp/current/ in place, DeployHQ writes each release into a sibling timestamped directory:
/var/www/myapp/
├── current -> releases/20260607T143012Z/
└── releases/
├── 20260607T143012Z/
├── 20260606T201144Z/
└── 20260605T091821Z/
The release directory is populated atomically: DeployHQ transfers the files, runs the post-deploy hook, and only after the hook returns successfully does it switch the current symlink to point at the new directory. From the kernel's perspective, the symlink switch is a single rename; the web server's proxy_pass target doesn't change, the Node process under current/ is the new one (under PM2 reload), and there's no half-deployed window where some files are new and others are old.
This is the atomic deployment pattern that powers the zero-downtime deployment flow across DeployHQ. For a React Router v7 SSR app it matters specifically because the asset filenames in build/client/assets/ are hash-fingerprinted between builds. Without an atomic switch, a reader's browser might load an index.html from release N and then request a now-removed asset from release N-1, getting a 404 mid-render. With the atomic switch, that race doesn't exist — every request resolves entirely against either the old release or the new one.
DeployHQ retains a configurable number of previous releases on the server so you can roll back without re-transferring files. The retention default is usually fine; if your node_modules/ directories are large, lower the count to keep disk usage in check.
One more wrinkle worth knowing: because each release directory has its own node_modules/, you genuinely do reinstall on every deploy. That's the trade-off for the atomic switch — you can't share a single node_modules/ across releases without losing the property that rolling back gives you a complete, working tree. The install time is the cost you pay; in practice it's seconds, not minutes, off a warm npm cache.
Multi-environment branch mapping
Two servers, two branches, one project. That's the recommended shape for staging and production on DeployHQ for a React Router v7 app.
Attach a second server to the project alongside the production one. The configuration is identical except for three fields:
- Branch. Point staging at the
stagingbranch (or whatever your team uses for the pre-production line). - Deployment path. Use a different directory on the same host (
/var/www/myapp-staging/) or a different host entirely if your environments don't share infrastructure. - Config variables. Each server gets its own config-variables block. This is where staging and production diverge:
DATABASE_URLpoints at the staging database; the session secret is different; analytics or feature-flag IDs differ; the public site URL isapp-staging.example.cominstead ofapp.example.com.
From that point on, pushing to main triggers a deploy to production; pushing to staging triggers a deploy to staging. Neither push touches the other environment. This is the automatic deployment from Git flow in its most useful shape: the branch-to-server mapping is the entire deployment policy. You don't need a separate CI workflow that decides which branch goes where, and there's no place for the staging deploy to accidentally land in production.
Two design choices worth being explicit about, because they come up almost every time:
One project with two servers, or two projects with one server each. Both work. One project is the simpler default — same repository, same build pipeline, same exclude rules; the only thing that varies between environments is the server-level config. Two projects gives you tighter isolation: a typo in the production server's exclude rules can't accidentally affect staging, and you can run different build commands per environment (rarely a good idea, but sometimes necessary). For most teams shipping a single React Router v7 app, one project with two servers is fine. Reach for two projects when you have audit requirements that map "deploy permissions" to specific people per environment, or when your staging environment runs a meaningfully different build (a feature-flagged variant, say).
Per-environment env vars. The Postgres template's DATABASE_URL is the most important one to get right. Staging hits a staging database. Production hits the production database. Don't get clever about running staging against a read-only replica of production — the read-write surface in your app will silently start writing to the wrong place, and a stray migration will be running against production schema when you expected isolation. Two databases, two URLs, two config-variables blocks. Same goes for session secrets, third-party API keys, and anything that touches money or PII.
If you need a third environment — a preview server, an internal review environment for marketing — the pattern scales the same way: add a server, map it to a branch, give it its own config-variables block. There's no per-environment cost beyond the server slot in your DeployHQ plan and whatever you pay for the host itself.
A note on promoting builds. DeployHQ runs a fresh build on every deploy, which means the artefact that ships to staging is not byte-for-byte identical to the artefact that ships to production, even from the same commit. Vite and the React Router build can produce timestamps, hashes that vary subtly across runs, or generated banners that differ between builds. For most React Router v7 apps this isn't operationally meaningful; the staging environment exists to validate that the app renders and behaves correctly, not to validate the precise bytes that will ship to production. If you do need a stricter promotion model, the standard approach is to build once on staging, store the artefact tree externally (an S3 bucket, an internal package registry), and have the production deploy pull from that store rather than running a fresh build. That's enough deviation from the canonical DeployHQ flow that it's outside this guide's scope — but knowing the option exists is useful for teams with compliance or audit requirements.
Test the staging path before opening it to writers. The first deploy to staging is the right time to find out that the production server's reverse-proxy config doesn't match staging's, that you've forgotten to provision a staging database, or that the SSL certificate on the staging hostname expired six months ago and nobody noticed. Catch those things on the day you wire staging up rather than on the day a content reviewer is trying to look at a PR.
One-click rollback for React Router v7
Rollback on DeployHQ is a symlink switch. Every successful deploy stays in the releases/ directory until retention prunes it; rolling back picks one of the old release directories and updates current to point at it. PM2's reload (or systemd's restart) brings up the previous release's Node process against the previous release's node_modules/. Total elapsed time is the click plus a sub-second symlink update plus the process-restart window.
That's the mechanical story. The operational story has a few wrinkles for React Router v7 specifically.
The runtime tree is intact. Because each release directory has its own node_modules/, rolling back gives you a complete, working tree without re-running npm ci. If you tried to share a single node_modules/ across releases you'd save disk, but you'd lose this property — rolling back would need to re-install whatever the previous release was pinned to. The default DeployHQ atomic-release pattern keeps the tree per-release, which makes rollback genuinely fast.
Static assets are part of the rollback. A React Router v7 deploy's build/client/assets/ directory is hashed at build time, so the previous release's asset URLs differ from the new release's. Rolling back to the previous release means the previous release's build/client/ is served again, and the asset URLs line up with whatever index.html (or SSR-rendered output) the previous release emits. Where this gets interesting is if you've fronted the site with a CDN: the CDN edge may still have the new release's HTML cached, which references new release's asset hashes that no longer exist after the rollback. The fix is to invalidate the HTML paths on the CDN at the same time as the rollback. For Cloudfront, an invalidation against / and /index.html and the SSR routes is enough; CDN-specific invalidation patterns are outside this guide's scope, but the principle is the same across providers.
One-click rollback is what it sounds like. Open the project's deploy history, pick the release you want to roll back to, and click the rollback button. DeployHQ updates the current symlink on the server, fires the post-deploy hook again (which reloads the process), and the previous release is live. The whole loop is documented under one-click rollback; the mechanics are the same as a normal deploy, just pointing at an older release directory.
The Postgres template's migrations don't auto-rollback. This is the most important caveat to internalise. When you deploy with the node-postgres template's post-deploy hook, npm run db:migrate runs Drizzle migrations forward against the database. When you roll back the deploy, the file tree returns to the previous release — but the database schema does not. The new release's migrations are still applied. Two implications:
The first implication is that if your forward migration is destructive — dropping a column, narrowing a type, removing a table — the previous release will fail at runtime against the migrated database. Treat destructive migrations as a separate change that's deployed after the code that no longer reads the dropped column has been live for at least one cycle. The expand-and-contract pattern is the textbook approach: ship the additive migration in one deploy, ship the code that uses the new shape in the next, ship the destructive migration only after the code that depended on the old shape is fully gone. Rollback within an expand-and-contract sequence is safe; rollback across a contract step is what bites you.
The second implication is that if you need to actually revert a migration, you need a separate "down" migration deployed forward, not a rollback. Drizzle's migration model is forward-only; the way to undo a migration is to write a new migration that reverses it and ship it as a normal forward deploy. The post-deploy hook will run it as part of the next deploy, and the schema will be back to where it was before the original migration ran.
For the default and node-custom-server templates, none of this applies — there's no database in the picture, and rollback is the symlink switch and nothing else.
Pair React Router v7 with DeployHQ: the full Git-to-production flow
Here's what every push to main actually does, end to end:
flowchart LR
A[git push main] --> B[Webhook → DeployHQ]
B --> C[Clone repo into build runner]
C --> D[npm ci && npm run build]
D --> E[Transfer artefacts via SSH]
E --> F[New release directory written]
F --> G[Post-deploy hook: npm ci --omit=dev]
G --> H[Migrations if Postgres template]
H --> I[pm2 reload or systemctl restart]
I --> J[Symlink switch: current → new release]
J --> K[Live React Router v7 app]
The interesting properties of this pipeline aren't any single step — they're how the steps compose.
The build is reproducible. Lockfile-pinned installs (npm ci) and pinned Node versions on both the build runner and the target server mean the same commit produces the same output every time. Deploy log a year from now will show the same dependency tree, the same build commands, the same artefact layout. Reproducibility isn't a feature; it's the absence of mystery when something breaks.
The artefact is what runs. What DeployHQ transfers to the server is what the Node process loads. There's no intermediate build step on the server, no in-place modification of files between transfer and start. If the build succeeded in the runner, the production tree is the build runner's output plus the production-only node_modules/. Debugging a "works in build, fails in production" issue collapses to comparing exactly two things: the runner's environment and the server's environment.
The release is atomic. Either the new release is current or the old one is — never both, never neither. The transfer can fail mid-flight without affecting the live site (DeployHQ doesn't update the symlink until the post-deploy hook returns success). The release that's live is the release whose post-deploy hook completed.
The reload is zero-downtime under PM2. New process spins up against the new release's tree, takes over connection acceptance, old process drains and exits. Connections in flight on the old process complete on the old process; new connections land on the new process. The window where both processes coexist is short, and the only thing the client sees is a slight latency wiggle (if anything).
Rollback is the same shape as deploy. Update the symlink, re-run the post-deploy hook against the previous release (which is a no-op for the install because node_modules/ is already there), reload the process. From the dashboard, it's one click; from the server's perspective, it's the same symlink switch with the same atomicity guarantees.
The flow above is the same regardless of which template you started from. Default template, custom Express server, custom Express + Drizzle — the build runs in the runner, the artefacts ship over SSH, the post-deploy hook installs and reloads, the symlink switches. What changes between templates is the post-deploy hook's start command and (for Postgres) the migration step. The rest of the pipeline is shared.
If reading the above made you want to wire it up for real, start a free DeployHQ project and connect your React Router v7 repo. The free plan covers one project — enough to run a full production deploy end-to-end against your existing server.
Troubleshooting
These are the failure modes that account for most of the support questions on React Router v7 deploys to BYO-server targets. Each one has a short description, a root cause, and the fix.
react-router-serve: command not found after the post-deploy hook runs
The build succeeds, the transfer succeeds, the post-deploy hook fails when it tries to start the process. The diagnostic is react-router-serve: command not found (or similar for node ./server.js if node itself isn't on the PATH for the deploy user's non-interactive shell).
The cause is almost always that npm ci --omit=dev didn't run before the start command, or that it ran but npx resolution isn't being used. react-router-serve is provided by the @react-router/serve package, which is in the default template's dependencies. It lands in node_modules/.bin/ after npm ci --omit=dev finishes, and your start command needs to invoke it through npx (which resolves node_modules/.bin/ for you) or with the explicit path (./node_modules/.bin/react-router-serve ./build/server/index.js).
If you're using the PM2 ecosystem file approach, the script: "npx" and args: "react-router-serve ./build/server/index.js" pattern in the example above resolves react-router-serve via npm's PATH-walking and works correctly. For systemd, point ExecStart at the explicit binary path.
If the hook is running npm ci --omit=dev but the binary still isn't found, check whether the package is in dependencies (not devDependencies) in your package.json. The default template ships it as a runtime dependency; if you've moved it, move it back.
Build succeeds in the runner, transfer is empty on the server
The build log shows everything green, but the server's release directory is empty (or contains only the source files). The problem is almost always the deployment subdirectory or the exclude-files configuration.
Open the server settings in the DeployHQ project and confirm the deployment subdirectory is set to the repository root (the default, which is an empty path in the UI). If it's set to build/, DeployHQ will only ship the build output and your server will be missing package.json, package-lock.json, and (for the Express templates) server.js — none of which the Node process can run without.
Check the exclude-files configuration next. The exclude set in the build-pipeline section above is intentional, but if you've added build/ to the excludes, the build output won't ship and the runtime will have no SSR bundle to load. The exclude list should exclude source and config files, never build/ itself.
npm ci --omit=dev fails with EACCES on the server
The post-deploy hook errors out trying to write to node_modules/. The cause is that the release directory's parent isn't writable by the deploy user, or that an earlier deploy left a node_modules/ owned by root and the current install can't overwrite it.
The fix in both cases is to confirm the entire /var/www/myapp/ tree (or whichever deployment path you've chosen) is owned by the deploy user recursively:
sudo chown -R deploy:deploy /var/www/myapp
Then redeploy. If the failure persists, check that no previous release directory has files owned by a different user — find /var/www/myapp/releases -not -user deploy will list any offenders.
PM2 process exits silently after restart
The post-deploy hook runs without error, pm2 reload reports success, but pm2 list shows the process in errored state and nothing is responding on the expected port.
The first thing to check is pm2 logs myapp. The Node process is failing at startup, and the log will show why. The most common reasons are missing runtime environment variables (the process can't find DATABASE_URL and throws), a port collision (something else is already bound to port 3000), or a native-module load failure because the build runner's Node major and the server's Node major don't match.
For the missing-env-var case, confirm your env file (or PM2 ecosystem env block, or systemd EnvironmentFile=) is present, has the right contents, and is readable by the deploy user. For port collisions, check ss -tlnp | grep :3000 and either kill the conflicting process or change the PORT env var on the React Router app. For native-module mismatches, align the Node majors between DeployHQ's build runner and the server, and redeploy.
Postgres template: DATABASE_URL is required on startup or migration
The Drizzle config throws immediately if DATABASE_URL is unset, which surfaces during the npm run db:migrate step in the post-deploy hook. The post-deploy hook environment doesn't automatically inherit a .env file or PM2 env blocks; you need to set the variable explicitly for the migration step.
Two patterns work. The first is to source the env file inline in the hook:
cd %path%
npm ci --omit=dev
set -a && source /var/www/myapp/shared/myapp.env && set +a
npm run db:migrate
pm2 reload myapp || pm2 start ./server.js --name myapp
set -a exports every variable assigned after it; sourcing the env file then makes its variables available to subsequent commands; set +a turns automatic export back off. npm run db:migrate sees DATABASE_URL and runs.
The second pattern is to set the variable directly in the DeployHQ server's config-variables block, which makes it available to the post-deploy hook environment without needing the source step. This is the cleanest approach if you're comfortable having the database URL stored on DeployHQ; if compliance requires the secret to live only on the server, the source-the-env-file pattern is the alternative.
Postgres template: --conditions development left in the start command
The template's package.json start script ships as node --conditions development server.js. Running it as-is in production picks up the development-flavoured exports from any package that uses Node's exports conditions, which is harmless for most cases but isn't what you want.
Drop the flag in your process manager's start command. If you're using PM2 directly:
pm2 start "node server.js" --name myapp
If you're using a PM2 ecosystem file, set script: "./server.js" and interpreter: "node" without the conditions flag. If you're using systemd, the ExecStart line in the unit file is yours to write — no flag, no problem.
Asset 404s mid-deploy without a CDN
You don't have a CDN in front of the site, and you're still seeing intermittent asset 404s during a deploy. The cause is almost always that the symlink switch and PM2 reload haven't actually been atomic in practice — perhaps because the post-deploy hook is restarting the process before DeployHQ updates the current symlink, which means the new process starts up serving the new release's assets while requests are still resolving against the old current/build/client/.
The order matters. The symlink switch should happen before the reload, so the reload picks up the new release as current/. DeployHQ's atomic-release flow handles this by default on SSH targets — the file transfer completes, then the post-deploy hook runs against the new release directory's path, then the symlink updates. If you've customised the hook to restart the process from a hardcoded /var/www/myapp/current/ path (instead of using %path% which expands to the new release directory), the order can invert in subtle ways. Use %path% in the hook, not the symlink path.
Build hangs on npm ci against a private registry
If you're installing packages from a private npm registry, the build runner needs the auth token. Set it as a DeployHQ config variable on the project (or on the build environment specifically), and reference it from a .npmrc you generate at the start of the build:
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
npm ci && npm run build
The .npmrc lives only in the build runner and never ships to the server (it's not in the deployment tree). For a per-org registry, swap the registry URL accordingly.
engines.node warning during install
If you've set engines.node in package.json to a stricter version than the runner's Node major, npm ci emits a warning (or fails outright if you've also set engine-strict=true in .npmrc). Match the runner's Node major in engines.node, or set a range (>=20.0.0 <23.0.0) that covers both your runner and your server.
Reverse proxy serving stale assets after a deploy
The deploy succeeds, pm2 list shows the new process is up, but the browser still loads the previous release's assets. The usual culprits are an HTTP cache layer between your browser and the Node process — an Nginx proxy_cache directive, a Varnish layer, a CDN edge — that's still serving the previous release's bytes.
For a no-CDN setup, an Nginx proxy_cache directive is the most common cause. If you've added one for performance, configure it to bypass the cache on assets that change between releases (everything except /build/client/assets/*, which is fingerprinted and safe to cache for a year). The typical pattern is to add an explicit proxy_cache_bypass for HTML responses or to disable proxy caching on the location block that fronts the Node process and let the asset directory handle its own cache headers.
For a CDN-fronted setup, invalidate the HTML paths and the SSR-rendered route paths on every deploy. DeployHQ can run a post-deploy command against your CDN's API (CloudFront, Cloudflare, Fastly all expose invalidation endpoints); store the API credentials as DeployHQ config variables and run the invalidation as a final step in the post-deploy hook.
cd: %path%: No such file or directory in the post-deploy hook
The hook fails on the very first line. This is almost always a typo in the path variable — %path% is the correct DeployHQ template variable for the release directory; anything else (a literal hardcoded path, a shell variable like $DEPLOY_PATH that doesn't exist in this environment) won't expand. Open the hook configuration and confirm the variable is exactly %path%. If you're testing the hook manually over SSH, the template variables only expand inside DeployHQ's runner — you can't paste the hook into a terminal and expect %path% to be defined.
Postgres template migrations time out on connection
npm run db:migrate hangs and the post-deploy hook eventually fails. The DATABASE_URL is set, but the Drizzle migration can't reach the database. Common causes: the database is on a private network and the server isn't allowed to reach it, the database firewall hasn't been updated with the server's IP, or the connection string is using the wrong port.
The fastest diagnostic is to connect to the database manually from the server using the same DATABASE_URL: psql "$DATABASE_URL" will surface authentication, network, and SSL issues immediately. Fix those and the migration step will run.
A related variant: the migration runs but takes long enough that DeployHQ's post-deploy hook times out. For long-running migrations (large indexes, bulk data backfills), the right answer isn't to extend the hook timeout — it's to move the migration outside the deploy. Apply the migration manually before the deploy, then deploy code that's compatible with the migrated schema. The post-deploy hook stays fast; the schema change runs on its own schedule.
Build pipeline doesn't trigger on push
You pushed to main, the commit shows up on GitHub or GitLab, but no deploy appears in the DeployHQ dashboard. Two likely causes: the webhook between your Git provider and DeployHQ isn't installed (or has been removed), or the branch on the DeployHQ server isn't set to main.
For the webhook, the project's integration settings show the webhook status; re-installing it is a few clicks. For the branch, open the server configuration and confirm the deployment branch is the one you're pushing to. If you've renamed the default branch from master to main since wiring up the project, the server might still be watching master.
Closing and next steps
You've now got a pipeline that takes a React Router v7 repository — default template, custom Express server, or the Postgres variant — and turns every push to main into a clean build, an atomic release on your own server, a production-only dependency install, a Node process reload, and one-click rollback if it breaks. The setup is small once it's wired: one project, one build pipeline (npm ci && npm run build), one post-deploy hook for the install-and-restart step, one server per environment. The rest is what your application needs to run — reverse proxy, TLS, the runtime env vars you'd need on any Node deploy.
The shape of the deploy matters more than any individual feature here. The build runs in a clean managed environment, so your laptop's Node version and your CI's Node version don't drift. The artefact is what runs in production, so there's no "the build worked but the server install failed" failure mode. The release is atomic, so there's no half-deployed state for your readers to see. Rollback is the same symlink switch as deploy, so the recovery path is as fast as the deploy path — and that's particularly useful given that the Postgres template's expand-and-contract migration discipline is the bit you'll need to internalise to make rollbacks safe.
If you've read this far without actually wiring it up, the next step is to create a free DeployHQ account, connect your React Router v7 repository, attach your server, and trigger the first deploy. The free plan covers one project end-to-end, which is enough to validate the pipeline against your real app before deciding whether to scale up.
For adjacent deployment guides on the same surface, the Aider terminal-based AI pair programmer guide covers the developer-workstation half of the loop, the Cursor editor guide walks through pairing AI-assisted editing with Git-driven deploys, and the Cline open-source AI assistant guide covers the same flow for teams who prefer an open-source AI tool. The full list of framework-specific deployment guides lives in the DeployHQ guides index.
Questions? Email support@deployhq.com or reach us at @deployhq.