---
title: "Deploy a Node.js app"
description: "Deploy a Node.js or Express app to an exe.dev VM: git push to deploy, Node.js 26 LTS under systemd, SQLite on a persistent disk, HTTPS, backups."
url: https://exe-staging.dev/deploy/nodejs
category: "Frameworks"
updated: 2026-10-31
---

# Deploy a Node.js app

Your Express app on a server of its own. git push to deploy.

- **Software:** Node.js (https://nodejs.org)
- **Source:** https://github.com/nodejs/node
- **License:** MIT
- **Time:** 10 minutes
- **Example VM name:** `my-nodejs` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe-staging.dev/deploy/nodejs/manifest.json

## What you get

- Node.js 26 and the app answering on port 8000 as app.service, enabled to come back after a reboot.
- A ~/app.git repository whose pre-receive hook deploys each push to main into a new release and rejects the push if the deploy fails.
- Settings and secrets in ~/app.env (mode 600), read by the deploy and by the server.
- Data in ~/data, backed up nightly by app-backup.timer, with the newest backup opened and checked in a scratch directory.
- With REPO=new, a fresh Express 5 app whose guestbook saves messages to SQLite.

Running on the VM:

- **Your Node.js app**: npm start, as app.service under systemd, port 8000, data in `~/app → the current release in ~/releases`
- **Node.js 26 LTS**: NodeSource's apt package; npm, plus pnpm and Yarn through Corepack
- **git push deploys**: A pre-receive hook in ~/app.git installs, builds, migrates, and switches
- **SQLite**: Node's built-in node:sqlite in new apps; settings in ~/app.env, data in `~/data`
- **Nightly backup**: app-backup.timer; data, settings, and the repository, data in `~/backups`

How it is reached:

- https://my-nodejs.exe.xyz/ (private): Your app, until you make it public
- my-nodejs.exe.xyz:app.git (private): `git push exe main` deploys

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Node.js%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Fnodejs.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20to%20deploy%2C%20as%20owner%2Fname%20or%20a%20URL%2C%20or%20%60new%60%20to%20start%20a%20fresh%20Express%20app%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-nodejs.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-nodejs.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Node.js%2026%20and%20the%20app%20answering%20on%20port%208000%20as%20app.service%2C%20enabled%20to%20come%20back%20after%20a%20reboot.%0A-%20A%20~%2Fapp.git%20repository%20whose%20pre-receive%20hook%20deploys%20each%20push%20to%20main%20into%20a%20new%20release%20and%20rejects%20the%20push%20if%20the%20deploy%20fails.%0A-%20Settings%20and%20secrets%20in%20~%2Fapp.env%20%28mode%20600%29%2C%20read%20by%20the%20deploy%20and%20by%20the%20server.%0A-%20Data%20in%20~%2Fdata%2C%20backed%20up%20nightly%20by%20app-backup.timer%2C%20with%20the%20newest%20backup%20opened%20and%20checked%20in%20a%20scratch%20directory.%0A-%20With%20REPO%3Dnew%2C%20a%20fresh%20Express%205%20app%20whose%20guestbook%20saves%20messages%20to%20SQLite.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20Follow%20%22Set%20up%20by%0Ahand%22%20from%20%22Install%20Node.js%22%20to%20the%20end%20of%20it%2C%20as%20exedev%3B%20the%20walkthrough%0Aabove%20it%20is%20for%20the%20owner%20afterwards.%20Do%20not%20run%20Restore%20or%20Update%2C%20and%0Ado%20not%20reboot.%20Leave%20public%20sharing%20off.%0AREPO%20picks%20one%20path%20in%20%22Get%20the%20code%22%3A%20%60new%60%20starts%20a%20fresh%20app%3B%0Aanything%20else%20is%20a%20GitHub%20repository%20%28turn%20URLs%20into%20owner%2Fname%29.%20Never%0Acommit%20to%20a%20repository%20you%20cloned%3A%20its%20settings%20go%20in%20~%2Fapp.env.%20If%20a%0Aclone%20fails%2C%20follow%20the%20private-repository%20steps%2C%20and%20if%20that%20fails%0Atoo%2C%20give%20the%20owner%20the%20integration%20command%20and%20wait%20for%20them.%0AFor%20a%20cloned%20app%2C%20fill%20~%2Fapp.env%20from%20its%20.env.example%20%28or%20README%29%3A%0Agenerate%20secrets%20with%20openssl%2C%20ask%20the%20owner%20for%20keys%20to%20outside%0Aservices%2C%20and%20never%20invent%20values.%20If%20it%20uses%20Bun%20or%20doesn%27t%20start%20with%0A%60npm%20start%60%2C%20follow%20%22Hono%2C%20Fastify%2C%20and%20Bun%22.%20If%20a%20deploy%20fails%2C%20read%0Aits%20output%2C%20fix%20what%20you%20can%20without%20editing%20the%20code%2C%20and%20redeploy%0Awith%20~%2Fapp.git%2Fhooks%2Fpre-receive%20HEAD.%20The%20recipe%27s%20checks%20are%20enough%3A%0Adon%27t%20push%20test%20commits%20to%20~%2Fapp.git%2C%20which%20becomes%20the%20owner%27s%20history.%0AFinish%20with%20the%20app%27s%20URL%20and%20the%20git%20commands%20the%20owner%20runs%20on%20their%0Aown%20computer%2C%20from%20%22Deploy%20with%20git%20push%22%3A%20%60git%20clone%20-o%20exe%60%20for%20a%20new%0Aapp%2C%20%60git%20remote%20add%20exe%60%20for%20a%20GitHub%20repository%2C%20then%0A%60git%20push%20exe%20main%60%20%28or%20their%20default%20branch%29.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe-staging.dev/deploy/nodejs/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

A Node.js app wants a server that stays up. On an exe.dev VM your app
runs all the time under systemd: no cold starts, no sleeping free tier,
WebSockets that stay open, and a SQLite database on a real disk that's
still there tomorrow. You deploy with `git push`, as on Heroku; exe.dev
adds HTTPS, and the app stays private until you share it. Shelley, the
agent on the VM, can read your logs and fix things when you ask.

This works for the Express, Fastify, Hono, or plain `node:http` app you
already have on GitHub, or a fresh Express 5 app with a small guestbook.
Bun apps work too, with [two changes](#hono-fastify-and-bun).

## Open your app

When setup finishes, open `https://my-nodejs.exe.xyz/` and sign in to
exe.dev if asked. A new app shows a guestbook; sign it. Your message goes
into SQLite in `~/data/app.db`, signed with your exe.dev name, and it's
still there after a redeploy or a reboot.

## Deploy with git push

Your VM holds a git repository, `~/app.git`, and deploys whatever you push
to its `main` branch, like `git push heroku main` used to. Set it up once
**on your computer**.

If Shelley started a new app, clone it from the VM:

```bash
git clone -o exe my-nodejs.exe.xyz:app.git my-nodejs
cd my-nodejs
```

If you brought a GitHub repository, add the VM as a second remote in your
usual checkout. GitHub stays your source of truth; `exe` is where you
deploy:

```bash
git remote add exe my-nodejs.exe.xyz:app.git
```

Then change something, commit, and push:

```bash
git push exe main
```

Use your default branch's name if it isn't `main`. The deploy streams back
into your terminal and ends with `==> Deployed abc1234: HTTP 200 on port
8000`. If `ssh` can't find the VM that way, use
`vm+my-nodejs@vm.exe.xyz:app.git` as the remote's address
([why](/docs/faq/ssh-destination)).

Each push builds a new release next to the running one: a clean copy of
the commit, `npm ci` (or pnpm, Yarn, or Bun, matching your lockfile),
your `build` script if you have one (TypeScript, say), and your
`db:migrate` script if you have one. Only then does `~/app` switch to it
and `app.service` restart, which takes a second. If anything fails,
including a new version that doesn't answer on port 8000, the hook puts
the old version back and git rejects the push:
`! [remote rejected] main -> main (pre-receive hook declined)`. The VM's
`main` stays at the commit that's running, as on Heroku. Migrations that
already ran stay applied; there's a backup from just before them.

To roll back, push an older commit: `git push -f exe <good-sha>:main`.

To work locally, run `npm install`, then `npm run db:migrate` and
`npm run dev` in a new app: it restarts on every change
(`node --watch`) and reads a `.env` file if you make one.

> [!TIP]
> Want `git push` to deploy and update GitHub at once? Give `origin` two
> push addresses: `git remote set-url --add --push origin
> git@github.com:you/app.git` and then `git remote set-url --add --push
> origin my-nodejs.exe.xyz:app.git`.

## Settings and secrets

Environment variables live in one file on the VM, `~/app.env`, as
`KEY=value` lines; `app.service` and the deploy hook both read it. Put
values containing spaces or `$` in single quotes. On the VM:

```bash
nano ~/app.env
sudo systemctl restart app
```

If your `build` step reads a variable (a bundler inlining it into browser
code, say), redeploy instead: `~/app.git/hooks/pre-receive HEAD`. The
service sets `NODE_ENV=production` and `PORT=8000`; a line in `~/app.env`
overrides either. Keep a copy of `~/app.env` somewhere safe; the nightly
backup includes it.

## Express behind the proxy

exe.dev's [HTTPS proxy](/docs/proxy) ends TLS and connects to your app
from 127.0.0.1, with the visitor's details in `X-Forwarded-Proto`,
`X-Forwarded-Host`, and `X-Forwarded-For`. The new app trusts those
headers from there, and only from there:

```js
app.set("trust proxy", "loopback");
```

Without it, `req.protocol` says `http`, `req.ip` is the proxy's address
for every visitor (so a rate limiter blocks everyone at once), and
`express-session` won't send a `secure` cookie, so logins don't stick.
Add the line to an app you brought. Don't use `true`: that trusts
whatever a client puts in the header.

The `Host` header is already the public name (`my-nodejs.exe.xyz`, or
your own domain), so links built from `req.hostname` are right.

## Your database

A new app uses SQLite through `node:sqlite`, which is built into Node.js:
no native module to compile, no ORM. The database is `~/data/app.db`
(`DATABASE_URL=file:/home/exedev/data/app.db` in `~/app.env`). SQLite on
the VM's own disk is fast, needs no server, and is easy to back up.

Migrations are numbered SQL files in `migrations/`. To change the schema,
add the next one **on your computer** and push:

```bash
echo 'ALTER TABLE messages ADD COLUMN mood TEXT;' > migrations/002-mood.sql
git add migrations && git commit -m "Add a mood" && git push exe main
```

The deploy runs `npm run db:migrate` (here `node migrate.js`, which
applies the files it hasn't yet and records the last one in SQLite's
`user_version`) after a backup and before the switch. That's the hook's
convention for any app: a `db:migrate` script that applies migrations
without prompting.

- **Prisma, Drizzle, Knex, Sequelize**: make `db:migrate` run their
  deploy command (`prisma migrate deploy`, `drizzle-kit migrate`,
  `knex migrate:latest`), or set `MIGRATE_COMMAND='npx prisma migrate deploy'`
  in `~/app.env` instead of adding a script.
- **PostgreSQL**: set `DATABASE_URL=postgresql://...` in `~/app.env`, a
  hosted database's or your own. To run PostgreSQL on this VM, with
  backups, see [PostgreSQL](/deploy/postgres#run-it-next-to-your-app).

Put uploads and any other files your app writes under `~/data` too, so
the backup picks them up. Releases are replaced on every deploy.

## Make it public, add a domain

The app is private: only people you've [shared it with](/docs/sharing)
can open it, after signing in to exe.dev. When you're ready for the world,
**on your computer**:

```bash
ssh exe.dev share set-public my-nodejs
```

`ssh exe.dev share set-private my-nodejs` closes it again. To serve it on
your own domain, point a CNAME at `my-nodejs.exe.xyz` and run
`ssh exe.dev domain add my-nodejs app.example.com`; exe.dev issues the
certificate ([custom domains](/docs/cnames)). Once the app is public,
visitors aren't signed in unless they sign in on purpose; see the next
section.

## Let exe.dev handle sign-in

For an internal tool, you may not need your own login at all. For
visitors signed in to exe.dev, the proxy adds `X-ExeDev-Email` and
`X-ExeDev-UserID` headers to each request
([login with exe](/docs/login-with-exe)), and it removes any a visitor
sends. On a private app, that's everyone who gets in. On a public one,
visitors have no header until they sign in at
`https://my-nodejs.exe.xyz/__exe.dev/login?redirect=/`. In Express:

```js
const email = req.get("x-exedev-email"); // undefined if signed out
```

The new app's guestbook signs messages this way, as "a visitor" for
anyone signed out. To require sign-in for some pages, send signed-out
visitors to that login page:

```js
// /admin is only for people signed in to exe.dev.
app.use("/admin", (req, res, next) => {
  if (req.get("x-exedev-email")) return next();
  res.redirect("/__exe.dev/login?redirect=" + encodeURIComponent(req.originalUrl));
});
```

Who may use `/admin` is then up to you: compare the email with a list in
`~/app.env`, say.

## Hono, Fastify, and Bun

The service runs `npm start` with `PORT=8000`, like Heroku, so any
Node.js server that listens on `process.env.PORT` deploys as it is.

- **Fastify** listens on 127.0.0.1 by default, which is where exe.dev's
  proxy connects, so `fastify.listen({ port: Number(process.env.PORT) })`
  is enough. Trust the proxy with `Fastify({ trustProxy: "127.0.0.1" })`.
- **Hono** on Node.js needs its adapter, `@hono/node-server`:
  `serve({ fetch: app.fetch, port: Number(process.env.PORT) })`.
- **No `start` script?** npm runs `node server.js` if that file exists.
  Otherwise add one: `npm pkg set scripts.start="node dist/index.js"`.

**Bun.** If your repository has a `bun.lock`, the hook installs with
Bun, which you add once. On the VM:

```bash
curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.4"
sudo ln -sf ~/.bun/bin/bun /usr/local/bin/bun
sudo mkdir -p /etc/systemd/system/app.service.d
printf '[Service]\nExecStart=\nExecStart=/usr/local/bin/bun run start\n' |
  sudo tee /etc/systemd/system/app.service.d/bun.conf
sudo systemctl daemon-reload
~/app.git/hooks/pre-receive HEAD
```

The link puts `bun` where the hook and systemd find it, and the drop-in
makes the service run your `start` script with Bun. Bun reads `PORT`,
so a Hono app's `export default app` needs no change. `bun upgrade`
updates it later.

## Cron jobs

Run scheduled work with systemd timers, the same way the backup runs: a
script in your app, run in the current release with your settings. For
example, a new app's `scripts/prune.js` that deletes guestbook messages
older than 90 days:

```js
import { db } from "../db.js";

const { changes } = db
  .prepare("DELETE FROM messages WHERE created_at < datetime('now', '-90 days')")
  .run();
console.log(`Deleted ${changes} old messages`);
```

Push it, then add a timer **on the VM**:

```bash
sudo tee /etc/systemd/system/app-prune.service >/dev/null <<'EOF'
[Unit]
Description=Delete old guestbook messages

[Service]
Type=oneshot
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
ExecStart=/usr/bin/node scripts/prune.js
EOF
sudo tee /etc/systemd/system/app-prune.timer >/dev/null <<'EOF'
[Unit]
Description=Delete old guestbook messages every night

[Timer]
OnCalendar=*-*-* 04:00:00 UTC
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-prune.timer
sudo systemctl start app-prune.service && journalctl -u app-prune -n 3 --no-pager
```

The last line runs it once and shows its output. `OnCalendar` reads like
cron: `*:0/15` is every 15 minutes, `Mon *-*-* 09:00` is Monday mornings.
A queue worker (BullMQ, say) is a long-running process instead: copy
`app.service` to `app-worker.service` with its own `ExecStart`, and add
`sudo systemctl restart app-worker` to the hook after the app's restart.

## Keep your data

Every night, `~/bin/app-backup` saves `~/data` (SQLite through its
`.backup` command, so the copy is consistent), `~/app.env`, and the whole
`~/app.git` repository into `~/backups/app-nightly-<time>.tar.gz`. Each
deploy that runs migrations saves an `app-deploy-` backup first. It keeps
the 14 newest of each kind. A backup on a lost VM won't help much, so copy
them off now and then. **On your computer**:

```bash
scp -r my-nodejs.exe.xyz:backups ./my-nodejs-backups
```

The files hold your secrets; keep them private. To make an extra backup
before something risky, run `~/bin/app-backup` on the VM; it prints the
archive's name.

## Ask Shelley

Shelley, at `https://my-nodejs.shelley.exe.xyz/`, works on this VM with
you. Try: "The last deploy failed; read the output and tell me why",
"Show me today's errors from journalctl -u app", "Add a JSON API for
the guestbook", or "Move my database to PostgreSQL on this VM". For a
staging copy to try things on first, see
[dev, test, and prod](/deploy/dev-test-prod).

## The nuts and bolts

Happy with what Deploy set up? You can stop here. These are the setup
steps Shelley follows, which you can run by hand, and the commands for
later.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Set up by hand</summary>

### Create the VM

On your computer (pick another name if `my-nodejs` is taken):

```bash
ssh exe.dev new --name=my-nodejs
ssh my-nodejs.exe.xyz
```

Everything else runs **on the VM**, as `exedev`.

### Install Node.js

The exeuntu image has no Node.js, or an old one. Install Node.js 26, the
current LTS release, from NodeSource's apt repository. It lands in
`/usr/bin`, where systemd, the git hook, and your shell all find the same
`node`, and `sudo apt upgrade` brings its security fixes.

```bash
curl -fsSL https://deb.nodesource.com/setup_26.x | sudo -E bash -
sudo apt-get install -y nodejs sqlite3 rsync
sudo npm install -g corepack && sudo corepack enable
node --version
```

Expect `v26.` and a patch version. Node.js 25 and later no longer ship
Corepack, which provides `pnpm` and `yarn` for repositories that use
them, hence the `npm install` line. The backup uses `sqlite3` and
`rsync`. If your app's `engines` field or `.nvmrc` asks for another
major version, use that number in the `setup_` URL instead.

### Settings, data, and the service

Create the data and release directories and a private settings file.
This leaves an existing `~/app.env` alone:

```bash
mkdir -p ~/data ~/releases ~/backups
if [ ! -e ~/app.env ]; then
  install -m 600 /dev/null ~/app.env
  cat >> ~/app.env <<'EOF'
# Settings and secrets for the app: KEY=value, one per line. Quote values
# with spaces or $ in 'single quotes'. Read by app.service and the deploy.
# After changing it: sudo systemctl restart app
EOF
fi
stat -c '%a %n' ~/app.env
```

Expect `600 /home/exedev/app.env`. Now the service. It runs `npm start`
in `~/app`, which will point at the current release, with `PORT=8000`,
where exe.dev's proxy sends `https://my-nodejs.exe.xyz/`:

```bash
sudo tee /etc/systemd/system/app.service >/dev/null <<'EOF'
[Unit]
Description=Node.js app (npm start in ~/app)
After=network-online.target
Wants=network-online.target

[Service]
User=exedev
WorkingDirectory=/home/exedev/app
Environment=NODE_ENV=production PORT=8000
# Lines in app.env override the two above.
EnvironmentFile=/home/exedev/app.env
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=3
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app.service
```

It starts with the first deploy. On a restart, systemd sends `SIGTERM`
to the app; the new app closes its server on that signal so requests
in flight can finish.

### Back up every night

`~/bin/app-backup` saves everything that isn't in a release: `~/data`
(each SQLite database through `.backup`, which is safe while the app
writes, and then checked), `~/app.env`, and the repository in
`~/app.git` as a git bundle. A new app's only copy lives there until you
clone it. It keeps the 14 newest backups of each kind: `nightly`,
`deploy` (the hook takes one before running migrations), and `manual`.

```bash
mkdir -p ~/bin
cat > ~/bin/app-backup <<'EOF'
#!/usr/bin/env bash
# Back up ~/data, ~/app.env and ~/app.git to ~/backups/app-<kind>-<time>.tar.gz.
# SQLite databases are copied with .backup, safe while the app runs, and
# checked. Keeps the 14 newest backups of each kind.
# Usage: app-backup [nightly|deploy|manual]
set -euo pipefail
umask 077
kind=${1:-manual}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$HOME/backups" "$work/data"
cd "$HOME/data"
rsync -a --exclude='*.db' --exclude='*.db-*' --exclude='*.sqlite*' ./ "$work/data/"
find . -type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) | while read -r db; do
  mkdir -p "$work/data/$(dirname "$db")"
  sqlite3 "$db" ".backup '$work/data/$db'"
  [ "$(sqlite3 "$work/data/$db" 'PRAGMA quick_check')" = ok ]
done
cp "$HOME/app.env" "$work/"
if git -C "$HOME/app.git" rev-parse -q --verify HEAD >/dev/null; then
  git -C "$HOME/app.git" bundle create -q "$work/app.bundle" --all
fi
out="$HOME/backups/app-$kind-$(date -u +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$out.part" -C "$work" .
mv "$out.part" "$out"
ls -1t "$HOME/backups/app-$kind-"*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
EOF
chmod +x ~/bin/app-backup
```

Run it every night with a systemd timer:

```bash
sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up the app's data, settings and repository to ~/backups

[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/bin/app-backup nightly
EOF
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the app every night

[Timer]
OnCalendar=*-*-* 03:00:00 UTC
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer
systemctl list-timers app-backup.timer
```

Expect the next run, tonight. You'll take the first backup once the app
is deployed.

### Get the code

The VM deploys from a bare git repository, `~/app.git`. Create it one of
two ways.

**Start a new app** (`REPO=new`): an empty repository whose branch is
`main`.

```bash
git init --bare -b main ~/app.git
```

**Bring your GitHub repository**: a copy of it. A public repository
clones directly; replace `OWNER/NAME`:

```bash
git clone --bare https://github.com/OWNER/NAME.git ~/app.git
git -C ~/app.git symbolic-ref --short HEAD
```

The last line prints the branch that will deploy: your default branch.

For a **private** repository, connect GitHub to exe.dev once (on the
[Integrations page](https://exe.dev/integrations)) and attach the
repository to this VM. On your computer:

```bash
ssh exe.dev integrations add github --name nodejs-repo --repository OWNER/NAME --readonly --attach vm:my-nodejs
```

Then clone through the integration on the VM. No token lands on the disk
([GitHub integration](/docs/integrations-github)):

```bash
git clone --bare https://github.int.exe.xyz/OWNER/NAME.git ~/app.git
```

If that clone fails with "not found" or a 403, the integration isn't
attached to this VM yet.

### Add the deploy hook

Git runs `hooks/pre-receive` before it accepts a push, as Heroku does.
This one builds the pushed commit in its own directory under
`~/releases`, runs migrations, switches `~/app` to it, restarts the
service, and checks that it answers. If any step fails, it switches
back and rejects the push, so `main` on the VM is always what's running.

```bash
cat > ~/app.git/hooks/pre-receive <<'EOF'
#!/usr/bin/env bash
# Deploys the app. Git runs this before accepting a push to ~/app.git;
# if the deploy fails, the push is rejected and the running version stays.
# To redeploy main by hand (after editing ~/app.env, say):
#   ~/app.git/hooks/pre-receive HEAD
set -euo pipefail
export GIT_DIR=$HOME/app.git
branch=$(git symbolic-ref --short HEAD)  # the branch that deploys
rev=
if [ $# -gt 0 ]; then
  rev=$(git rev-parse --verify "$1^{commit}")
else
  while read -r _ new ref; do
    [ "$ref" = "refs/heads/$branch" ] || continue
    case $new in
      *[!0]*) rev=$new ;;
      *) echo "==> Refusing to delete $branch: it's what deploys."; exit 1 ;;
    esac
  done
  [ -n "$rev" ] || exit 0  # other branches are kept, not deployed
fi

exec 9>"$HOME/.deploy.lock" && flock 9  # one deploy at a time
release=$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-${rev:0:7}
previous=$(readlink "$HOME/app" || true)
fail() {  # put the previous release back and reject the push
  trap - ERR
  echo "==> Deploy FAILED${1:+: $1}. Still running: ${previous:-nothing}"
  if [ -n "$previous" ] && [ "$(readlink "$HOME/app")" != "$previous" ]; then
    ln -sfn "$previous" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"
    sudo systemctl restart app
  fi
  rm -rf "$release"
  exit 1
}
trap fail ERR

echo "==> Building ${rev:0:7} in $release"
mkdir -p "$release"
git archive "$rev" | tar -x -C "$release"
unset GIT_DIR GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_QUARANTINE_PATH
cd "$release"
set -a; . "$HOME/app.env"; set +a
unset NODE_ENV  # install devDependencies too, for the build; app.service sets it
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile
elif [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile
elif [ -f yarn.lock ] && [ -f .yarnrc.yml ]; then yarn install --immutable
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile
elif [ -f package-lock.json ]; then npm ci
else npm install
fi
npm run build --if-present
migrate=${MIGRATE_COMMAND:-}
if [ -z "$migrate" ] && node -e 'process.exit(require("./package.json").scripts?.["db:migrate"] ? 0 : 1)'; then
  migrate="npm run db:migrate"
fi
if [ -n "$migrate" ]; then
  "$HOME/bin/app-backup" deploy >/dev/null  # back up before migrating
  bash -c "$migrate"
  echo
fi

echo "==> Starting the new version"
ln -sfn "$release" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"
sudo systemctl restart app
for _ in $(seq 60); do
  code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' http://localhost:8000/ || true)
  case $code in 000|5??) sleep 1 ;; *) break ;; esac
done
case $code in
  000|5??) fail "the new version answers HTTP $code (journalctl -u app -n 50)" ;;
esac
trap - ERR
ls -1d "$HOME"/releases/*/ | head -n -3 | xargs -r rm -rf  # keep 3
echo "==> Deployed ${rev:0:7}: HTTP $code on port 8000"
EOF
chmod +x ~/app.git/hooks/pre-receive
```

The hook installs with your lockfile's package manager, then runs the
`build` script if `package.json` has one and the `db:migrate` script if
it has one (or `MIGRATE_COMMAND` from `~/app.env`), after a `deploy`
backup. It counts any answer on port 8000 below 500 as up.

### Start a new app

Skip to [Deploy and check](#deploy-and-check) if you brought a
repository. Otherwise, make an Express 5 app with a script for
migrations, and point it at `~/data`:

```bash
rm -rf /tmp/new-app && mkdir -p "/tmp/new-app/$(hostname)" && cd "/tmp/new-app/$(hostname)"
git init -q -b main
npm init -y >/dev/null
npm pkg set type=module 'engines.node=>=26' 'scripts.start=node server.js' \
  'scripts.dev=node --watch --env-file-if-exists=.env server.js' 'scripts.db:migrate=node migrate.js'
npm pkg delete main scripts.test
npm install express@5
printf 'node_modules/\n.env\n*.db\n*.db-*\n' > .gitignore
echo 'DATABASE_URL=file:/home/exedev/data/app.db' >> ~/app.env
```

The database connection, the migration runner, and the first migration:

```bash
cd "/tmp/new-app/$(hostname)"
cat > db.js <<'EOF'
import { DatabaseSync } from "node:sqlite";

// On the VM, ~/app.env sets DATABASE_URL=file:/home/exedev/data/app.db
const file = (process.env.DATABASE_URL ?? "file:app.db").replace(/^file:/, "");
export const db = new DatabaseSync(file);
db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
EOF
cat > migrate.js <<'EOF'
// Applies migrations/NNN-*.sql in order, each in a transaction.
// SQLite's user_version remembers the last one applied.
import { readdirSync, readFileSync } from "node:fs";
import { db } from "./db.js";

const dir = new URL("migrations/", import.meta.url);
const done = db.prepare("PRAGMA user_version").get().user_version;
for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
  const n = parseInt(file, 10);
  if (n <= done) continue;
  db.exec("BEGIN");
  try {
    db.exec(readFileSync(new URL(file, dir), "utf8"));
    db.exec(`PRAGMA user_version = ${n}`);
    db.exec("COMMIT");
  } catch (err) {
    db.exec("ROLLBACK");
    throw err;
  }
  console.log(`Applied ${file}`);
}
EOF
mkdir -p migrations
cat > migrations/001-messages.sql <<'EOF'
CREATE TABLE messages (
  id INTEGER PRIMARY KEY,
  author TEXT NOT NULL,
  body TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
EOF
```

The app: a guestbook that saves messages signed with the visitor's
exe.dev name, and shuts down cleanly when systemd stops it.

```bash
cd "/tmp/new-app/$(hostname)"
cat > server.js <<'EOF'
import express from "express";
import { db } from "./db.js";

const app = express();
// exe.dev's proxy connects from 127.0.0.1: trust its X-Forwarded-* headers from there only.
app.set("trust proxy", "loopback");
app.use(express.urlencoded({ extended: false }));

const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

app.get("/", (req, res) => {
  const rows = db.prepare("SELECT author, body FROM messages ORDER BY id DESC LIMIT 50").all();
  const list = rows.map((m) => `<li><b>${esc(m.author)}</b>: ${esc(m.body)}</li>`).join("");
  res.send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Guestbook</title></head>
<body style="font-family: system-ui; max-width: 36rem; margin: 2rem auto; padding: 0 1rem">
<h1>Guestbook</h1>
<form method="post"><input name="body" required maxlength="280" placeholder="Say hello"> <button>Sign</button></form>
${list ? `<ul>${list}</ul>` : "<p>No messages yet. Be the first.</p>"}
</body></html>`);
});

app.post("/", (req, res) => {
  const body = String(req.body.body ?? "").trim().slice(0, 280);
  // exe.dev's proxy tells the app who is signed in.
  const email = req.get("x-exedev-email");
  const author = email ? email.split("@")[0] : "a visitor";
  if (body) db.prepare("INSERT INTO messages (author, body) VALUES (?, ?)").run(author, body);
  res.redirect(303, "/");
});

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, (err) => {
  if (err) throw err;
  console.log(`Listening on http://localhost:${port}`);
});
// systemd sends SIGTERM on restart: finish the requests in flight, then exit.
process.on("SIGTERM", () => server.close(() => process.exit(0)));
EOF
```

Commit and push to the VM's repository. The push deploys:

```bash
cd "/tmp/new-app/$(hostname)"
git add .gitignore package.json package-lock.json db.js migrate.js migrations server.js
git -c user.name="$(hostname)" -c user.email="exedev@$(hostname).exe.xyz" commit -qm "Express guestbook on SQLite"
git push ~/app.git HEAD:main
```

The output ends with `==> Deployed` and a commit. The code now lives in
`~/app.git` (the owner clones it from there), so remove the scratch copy:

```bash
cd ~ && rm -rf /tmp/new-app
```

### Deploy and check

If you brought a repository, put its settings in `~/app.env` first. Look
for `.env.example` or the README; generate secrets such as
`SESSION_SECRET` with `openssl rand -hex 32`. If it uses SQLite, point it
at `~/data`, for example `DATABASE_URL=file:/home/exedev/data/app.db`.
Then deploy its default branch:

```bash
~/app.git/hooks/pre-receive HEAD
```

(A new app deployed when you pushed it.) Check, for either kind:

```bash
systemctl is-enabled app; systemctl is-active app
readlink ~/app
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/
```

Expect `enabled`, `active`, the release directory, and `200` (or your
home page's usual status, such as a `302` to a login page). For a new
app, sign the guestbook through the port the proxy uses, as a signed-in
visitor would:

```bash
curl -s -o /dev/null -H 'X-ExeDev-Email: setup@example.com' --data-urlencode 'body=Hello from setup' http://localhost:8000/
curl -s http://localhost:8000/ | grep -o '<b>setup</b>: Hello from setup'
sqlite3 ~/data/app.db 'PRAGMA user_version; DELETE FROM messages'
```

Expect `<b>setup</b>: Hello from setup` and `1`, the migration applied;
the last command also removes the test message.

### Check the backup

Take the first backup by hand, then open it in a scratch directory, never
over `~/data`, to check that it holds what a restore needs:

```bash
latest=$(~/bin/app-backup manual)
rm -rf /tmp/restore-check && mkdir /tmp/restore-check
tar -xzf "$latest" -C /tmp/restore-check
ls -A /tmp/restore-check /tmp/restore-check/data
git -C ~/app.git bundle verify /tmp/restore-check/app.bundle
find /tmp/restore-check/data -type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) |
  while read -r db; do echo "$db: $(sqlite3 "$db" 'PRAGMA integrity_check')"; done
rm -rf /tmp/restore-check
```

Expect `app.bundle`, `app.env`, and `data`; `The bundle records a complete
history`; and `ok` for each database (a new app has `app.db`).

Setup is done. Continue with [Open your app](#open-your-app).

</details>

<details id="restore" class="dp-details">
<summary>Restore a backup</summary>

### On the same VM

On the VM, pick the archive (`ls -lt ~/backups`), unpack it, and swap
its `data` in while the app is stopped. The current data is kept aside:

```bash
backup=~/backups/app-nightly-20261031-030512.tar.gz   # the one you want
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf "$backup" -C /tmp/restore
sudo systemctl stop app
mv ~/data ~/data.before-restore-$(date +%s)
cp -a /tmp/restore/data ~/data
sudo systemctl start app
```

Open the app and check your data. If the backup is older than your
latest migration, apply it with `~/app.git/hooks/pre-receive HEAD`.
`~/app.env` is in `/tmp/restore` too, if you need an old setting back.
Delete `~/data.before-restore-*` when you're sure.

### On a new VM

Create the VM and follow [Set up by hand](#create-the-vm) up to, but not
including, "Get the code". Copy the archive over from your computer
(`scp app-nightly-20261031-030512.tar.gz my-nodejs.exe.xyz:`), then on the VM:

```bash
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf ~/app-nightly-20261031-030512.tar.gz -C /tmp/restore
git clone --bare /tmp/restore/app.bundle ~/app.git && git -C ~/app.git remote remove origin
install -m 600 /tmp/restore/app.env ~/app.env
rm -rf ~/data && cp -a /tmp/restore/data ~/data
```

Then follow [Add the deploy hook](#add-the-deploy-hook) and deploy with
`~/app.git/hooks/pre-receive HEAD`. On your computer, point the `exe`
remote at the new VM: `git remote set-url exe <new-vm>.exe.xyz:app.git`.

</details>

<details id="update" class="dp-details">
<summary>Update Express and Node.js</summary>

**Express** and your other packages are dependencies of your app, so
update them on your computer and deploy:

```bash
npm outdated
npm update
git commit -am "Update dependencies" && git push exe main
```

**Node.js** patch releases arrive with the system's updates. On the VM:

```bash
sudo apt-get update && sudo apt-get install -y nodejs
sudo systemctl restart app
node --version
```

To move to a newer major version (Node.js 27 is due to become LTS in
October 2027), take a backup, switch the apt repository, and redeploy
right away: if your app uses native modules, such as `better-sqlite3` or
`bcrypt`, the running release's copies were built for the old Node.js,
and the deploy rebuilds them.

```bash
~/bin/app-backup
curl -fsSL https://deb.nodesource.com/setup_27.x | sudo -E bash -
sudo apt-get install -y nodejs
~/app.git/hooks/pre-receive HEAD
```

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM, the app's logs:

```bash
journalctl -u app -n 100 --no-pager
```

- **`git push` says `Deploy FAILED` and `pre-receive hook declined`.**
  The lines above it are the error: a failed install or build, a
  migration error, or, after `Starting the new version`, an app that
  crashed or didn't answer (its logs are in `journalctl -u app`). The
  running version didn't change; fix it and push again.
- **`npm ci` fails: `package.json and package-lock.json are not in
  sync`.** Run `npm install` on your computer and commit
  `package-lock.json`.
- **The app starts, but the deploy says HTTP 000.** It isn't listening on
  port 8000: read `PORT` from the environment instead of hard-coding a
  port.
- **`Error: Cannot find module` at start.** A package the app needs at
  runtime is in `devDependencies`, or a TypeScript app's `start` script
  points at files the `build` script didn't write.
- **`SyntaxError: Cannot use import statement outside a module`.** Add
  `"type": "module"` to `package.json`, or use `require`.
- **Every visitor has the same IP, or logins don't stick.** Set
  `trust proxy`; see [Express behind the proxy](#express-behind-the-proxy).
- **A new environment variable has no effect.** `sudo systemctl restart
  app`; if the build uses it, redeploy with `~/app.git/hooks/pre-receive HEAD`.
- **`no such table`.** Migrations didn't run: check that `npm run
  db:migrate` exists and works, and look for its output in the push.
- **The site shows exe.dev's error page after a reboot.** Check
  `systemctl status app`; it should be `enabled`. `readlink ~/app` must
  point at an existing release.
- **Disk filling up.** `du -sh ~/releases/* ~/backups ~/.npm`; the hook
  keeps three releases, and `npm cache clean --force` empties npm's cache.

</details>

## Frequently asked questions

### Which Node.js version does this install?

Node.js 26, the Active LTS release since October 28, 2026, with security fixes until April 2029. It comes from NodeSource's apt repository, so systemd, the deploy hook, and your shell share one `node` in `/usr/bin` (nvm's per-user install is on no PATH systemd sees) and `apt upgrade` brings patch releases. If your app needs another major version, use its number in the `setup_` URL (`setup_24.x`).

### Do I need PM2 or forever?

No. systemd already does what they do: it starts the app at boot, restarts it if it crashes, and keeps its logs (`journalctl -u app`). One Node.js process uses one CPU core, which is plenty for most apps; if yours is CPU-bound, use Node's `cluster` module or a bigger VM.

### Do I need a Dockerfile?

No. The VM runs your app with Node.js directly, and SQLite and uploads live on an ordinary disk. If you'd rather ship a container, see [Docker Compose](/deploy/docker-compose).

### Do WebSockets and Server-Sent Events work?

Yes. exe.dev's [HTTPS proxy](/docs/proxy) passes WebSocket upgrades and streams responses, so Socket.IO, `ws`, and SSE work at `wss://my-nodejs.exe.xyz/` with no configuration. The server runs all the time, so connections aren't cut by a function timeout; they do drop for a second or two when a deploy restarts the app, so let clients reconnect.

### How big a VM does a Node.js app need?

The default (2 vCPUs, 8 GB of memory, 25 GB of disk) is far more than a typical Express app needs: the starter uses about 140 MB of memory, half of it the `npm start` wrapper. Each kept release carries its own `node_modules`, and the hook keeps three.

### I'm coming from Heroku, Railway, or Render. What changes?

Little in your code: the platform ran `npm start` with `PORT` set, and so does this VM. Config vars go in `~/app.env`, a Procfile `release:` command becomes a `db:migrate` script, and a `worker:` becomes a second systemd service. See [Migrate from Heroku](/deploy/migrate-heroku) for the rest.


## Related

- [Deploy Next.js on your own server](https://exe-staging.dev/deploy/nextjs.md): Your Next.js app on a server of its own. git push to deploy.
- [Deploy a Go web app](https://exe-staging.dev/deploy/go.md): One Go binary on a VM of its own. git push to deploy.
- [Self-host PostgreSQL](https://exe-staging.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Deploy a FastAPI app](https://exe-staging.dev/deploy/fastapi.md): Your API on a VM of its own. Push to deploy; the data stays put.
- [Dev, Test, Prod: Choose One, Two, or Three](https://exe-staging.dev/deploy/dev-test-prod.md): You can build, test, and deploy on exe.dev.
- [Migrate a Heroku app to exe.dev](https://exe-staging.dev/deploy/migrate-heroku.md): Your Procfile, config vars, and Postgres on a VM of your own.
