Skip to content

Deploy a Node.js app

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

Deploy on exe.dev

Shelley handles the setup. About 10 minutes.

Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/nodejs/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

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.

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.
What's running, and where
Private https://my-nodejs.exe.xyz/ Your app, until you make it public
Private my-nodejs.exe.xyz:app.git git push exe main deploys
my-nodejs.exe.xyz exe.dev VM
Your Node.js app npm start, as app.service under systemd :8000~/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
Nightly backup app-backup.timer; data, settings, and the repository ~/backups

Deploy with Shelley#

Click Deploy and Shelley will set up Node.js on a new VM, then check that it works. No config-file wrangling required.

Have these ready; Shelley asks for them:

  • REPO: the GitHub repository to deploy, as owner/name or a URL, or new to start a fresh Express app
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/nodejs/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What Shelley is told
REPO=<fill-this-in>

Set up Node.js on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/nodejs.md from top to bottom.

The values at the top of this message are mine:
- REPO: the GitHub repository to deploy, as owner/name or a URL, or `new` to start a fresh Express app
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-nodejs. This VM's name is the output of `hostname`: use it wherever the recipe writes my-nodejs. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- 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.

Read the whole recipe, including the closed details. Follow "Set up by
hand" from "Install Node.js" to the end of it, as exedev; the walkthrough
above it is for the owner afterwards. Do not run Restore or Update, and
do not reboot. Leave public sharing off.
REPO picks one path in "Get the code": `new` starts a fresh app;
anything else is a GitHub repository (turn URLs into owner/name). Never
commit to a repository you cloned: its settings go in ~/app.env. If a
clone fails, follow the private-repository steps, and if that fails
too, give the owner the integration command and wait for them.
For a cloned app, fill ~/app.env from its .env.example (or README):
generate secrets with openssl, ask the owner for keys to outside
services, and never invent values. If it uses Bun or doesn't start with
`npm start`, follow "Hono, Fastify, and Bun". If a deploy fails, read
its output, fix what you can without editing the code, and redeploy
with ~/app.git/hooks/pre-receive HEAD. The recipe's checks are enough:
don't push test commits to ~/app.git, which becomes the owner's history.
Finish with the app's URL and the git commands the owner runs on their
own computer, from "Deploy with git push": `git clone -o exe` for a new
app, `git remote add exe` for a GitHub repository, then
`git push exe main` (or their default branch).

Finish with the URLs I should open.

Also at /deploy/nodejs/prompt.txt. The recipe it follows is /deploy/nodejs.md.

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:

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:

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

Then change something, commit, and push:

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

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:

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

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:

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.

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 can open it, after signing in to exe.dev. When you're ready for the world, on your computer:

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). 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), 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:

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:

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

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:

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:

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:

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.

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.

Set up by hand

Create the VM#

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

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.

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:

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

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.

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:

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.

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

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

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) and attach the repository to this VM. On your computer:

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

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.

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 if you brought a repository. Otherwise, make an Express 5 app with a script for migrations, and point it at ~/data:

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:

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.

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:

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:

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:

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

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

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:

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:

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.

Restore a backup

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:

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

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

Update Express and Node.js

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

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:

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.

~/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
Something not working?

On the VM, the app's logs:

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

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.

Do WebSockets and Server-Sent Events work?

Yes. exe.dev's HTTPS 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 for the rest.

Updated Oct 31, 2026 · tested end to end Oct 31, 2026 · Markdown · manifest.json · Something wrong? Tell us