Skip to content

Deploy Next.js on your own server

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

Deploy on exe.dev

Shelley handles the setup. About 15 minutes.

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

Next.js runs best on a server that stays put. On an exe.dev VM your app runs next start all the time: no cold starts and no function timeouts, with ISR pages, optimized images, and a SQLite database on a real disk that's still there tomorrow. You deploy with git push, 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 app you already have on GitHub, or a fresh one from create-next-app. Leaving Vercel? See what changes.

What you get#

  • Node.js 24 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 build 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 create-next-app project whose /guestbook page saves messages to SQLite.
What's running, and where
Private https://my-nextjs.exe.xyz/ Your app, until you make it public
my-nextjs.exe.xyz exe.dev VM
Next.js 16 next start, as app.service under systemd :8000~/app → the current release in ~/releases
Node.js 24 LTS NodeSource's apt package; npm, pnpm, and Yarn
git push deploys A pre-receive hook in ~/app.git builds, migrates, and switches
SQLite Drizzle ORM 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 Next.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 Next.js app
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/nextjs/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What Shelley is told
REPO=<fill-this-in>

Set up Next.js on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/nextjs.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 Next.js app
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-nextjs. This VM's name is the output of `hostname`: use it wherever the recipe writes my-nextjs. 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 24 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 build 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 create-next-app project whose /guestbook page 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 a build fails, read its output,
fix what you can without editing the code, and redeploy with
~/app.git/hooks/pre-receive HEAD.
Finish with the app's URL (and /guestbook for a new app) 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/nextjs/prompt.txt. The recipe it follows is /deploy/nextjs.md.

Open your app#

When setup finishes, open https://my-nextjs.exe.xyz/ and sign in to exe.dev if asked. A new app shows the create-next-app welcome page; open /guestbook and sign it. Your message goes through a Server Action into SQLite in ~/data/app.db, 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-nextjs.exe.xyz:app.git my-nextjs
cd my-nextjs

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-nextjs.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 build 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-nextjs@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 or Yarn, matching your lockfile), next build, and your db:migrate script if you have one. Only then does ~/app switch to it and app.service restart, which takes a second or two. If the build or anything after it fails, including a new version that doesn't answer, 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.

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-nextjs.exe.xyz:app.git.

Settings and secrets#

Environment variables live in one file on the VM, ~/app.env, as KEY=value lines. Both the build and the running server read it. Put values containing spaces or $ in single quotes. On the VM:

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

The second line redeploys the current commit, so the change takes effect. Why rebuild, not just restart? Because Next.js reads some variables at build time:

  • NEXT_PUBLIC_* variables are copied into the JavaScript sent to browsers when next build runs. Changing one needs a rebuild, and anyone can read them, so never put secrets there.
  • Other variables stay on the server. Code that runs per request sees the current value after sudo systemctl restart app. But a page prerendered at build time keeps the value it was built with.

Keep a copy of ~/app.env somewhere safe; the nightly backup includes it.

Your database#

A new app uses SQLite through Drizzle ORM, with the database at ~/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. Drizzle is a thin, typed layer over SQL with no generated client or engine binary, and its migrations are plain SQL files you commit. Change the schema in db/schema.ts, then on your computer:

npm run db:generate        # writes a new SQL file in drizzle/
git add db drizzle && git commit -m "Add a column" && git push exe main

The deploy runs npm run db:migrate (here drizzle-kit migrate) after a successful build and before the switch. That's the hook's convention for any app: a db:migrate script that applies migrations without prompting.

  • Prisma: point it at the same disk with DATABASE_URL="file:/home/exedev/data/app.db" and add "db:migrate": "prisma migrate deploy". Keep prisma generate in postinstall or build, as on Vercel.
  • Other migration commands: set MIGRATE_COMMAND='npx prisma migrate deploy' (or whatever yours is) 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. Drizzle and Prisma both speak it.

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

Server Actions just work#

To stop cross-site request forgery, Next.js rejects a Server Action unless the browser's Origin matches the host the request was sent to. Behind most reverse proxies that check fails, and people reach for serverActions.allowedOrigins. You don't need it here: exe.dev's HTTPS proxy passes the public host in X-Forwarded-Host, and Next.js checks against that, for my-nextjs.exe.xyz, its other ports, and your own domain alike.

If you put something in between that rewrites that header, actions fail with Invalid Server Actions request in journalctl -u app, after a line like x-forwarded-host header with value `localhost:8000` does not match origin. Fix the proxy, or list the public host in next.config.ts:

experimental: { serverActions: { allowedOrigins: ["my-nextjs.exe.xyz"] } },

One thing does differ from Vercel: request.url in proxy.ts (formerly middleware.ts) and in route handlers says http://localhost:8000, so absolute URLs built from it point nowhere. Relative redirects, such as redirect("/login"), are fine. When you need the public origin, use X-Forwarded-Host, as in the exe.dev login example below. For Open Graph images and canonical links, set metadataBase: new URL("https://my-nextjs.exe.xyz") in your root layout's metadata.

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

ssh exe.dev share set-private my-nextjs closes it again. To serve it on your own domain, point a CNAME at my-nextjs.exe.xyz and run ssh exe.dev domain add my-nextjs app.example.com; exe.dev issues the certificate (custom domains). Next.js needs no change, and Server Actions keep working on the new host. 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). 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-nextjs.exe.xyz/__exe.dev/login?redirect=/. Read the headers in any Server Component, Server Action, or route handler:

import { headers } from "next/headers";

const email = (await headers()).get("x-exedev-email"); // null 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 from proxy.ts:

import { NextResponse, type NextRequest } from "next/server";

// /admin is only for people signed in to exe.dev.
export function proxy(request: NextRequest) {
  if (request.headers.get("x-exedev-email")) return NextResponse.next();
  // request.url says localhost:8000; the public host is in X-Forwarded-Host.
  const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  const login = new URL(`https://${host}/__exe.dev/login`);
  login.searchParams.set("redirect", request.nextUrl.pathname);
  return NextResponse.redirect(login);
}

export const config = { matcher: "/admin/:path*" };

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

Images, ISR, and the cache#

The features that need special care on serverless hosts are ordinary on one long-running server:

  • Image optimization. next/image works with no configuration: sharp comes with Next.js, and resized WebP files (AVIF too, if you add it to images.formats) are cached in .next/cache/images. The service preloads jemalloc, as sharp recommends on Linux; it avoids memory fragmentation and lets sharp use both CPUs.
  • ISR and prerendered pages live on disk in the release's .next directory. When a page with cacheLife("minutes") (or export const revalidate = 60 without Cache Components) goes stale, the next visitor gets the cached copy while Next.js renders a fresh one and writes it to disk, where it survives restarts and reboots.
  • revalidatePath, revalidateTag, and updateTag work as the Next.js docs describe, with no cache handler to write: there's one server, so its cache is the only one to update.
  • 'use cache' results stay in memory by default, so a restart empties them and they're refilled on demand.

Every deploy starts with a fresh cache, because Next.js keys cached entries by build. There's no CDN in front, but /_next/static files are served with immutable cache headers, so browsers keep them.

Cron jobs#

Run scheduled work with systemd timers, the same way the backup runs. The easy pattern, which matches Vercel Cron, is a route handler that the timer calls with a secret. In your app, app/api/cron/daily/route.ts:

export async function GET(request: Request) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  // ...the daily work: send digests, clean up old rows...
  return Response.json({ ok: true });
}

Then, on the VM, add a secret and a timer:

grep -q '^CRON_SECRET=' ~/app.env || echo "CRON_SECRET=$(openssl rand -hex 32)" >> ~/app.env
sudo systemctl restart app
sudo tee /etc/systemd/system/app-cron-daily.service >/dev/null <<'EOF'
[Unit]
Description=Call the app's daily cron route

[Service]
Type=oneshot
User=exedev
EnvironmentFile=/home/exedev/app.env
ExecStart=/usr/bin/curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" http://localhost:8000/api/cron/daily
EOF
sudo tee /etc/systemd/system/app-cron-daily.timer >/dev/null <<'EOF'
[Unit]
Description=Run the app's daily cron route at 05:00 UTC

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

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

The last line runs it once and shows the route's answer. OnCalendar reads like cron: *:0/15 is every 15 minutes, Mon *-*-* 09:00 is Monday mornings. For long jobs, skip the route and run a script in the app's directory: WorkingDirectory=/home/exedev/app and ExecStart=/usr/bin/node scripts/nightly.mjs.

Coming from Vercel#

Your code mostly moves as is. What changes:

  • Deploys. git push exe main instead of Vercel's GitHub app. There are no preview deployments; for staging, make a second VM from this recipe (dev, test, prod).
  • Environment variables. Run vercel env pull .env.production.local --environment=production on your computer and copy the values into ~/app.env. Vercel's own variables (VERCEL_URL, VERCEL_ENV) don't exist here; set metadataBase and any site URL yourself.
  • Build settings. The hook installs with your lockfile's package manager and runs your build script. If you changed the install or build command in Vercel's project settings or vercel.json, or have a vercel-build script, make build do the same, or edit the hook.
  • vercel.json. Crons become timers. Move redirects, rewrites, and headers into next.config.ts.
  • Storage. Vercel Blob becomes files in ~/data. Hosted databases from the Marketplace, such as Neon Postgres or Upstash Redis, keep working from here with the same URLs; or move the data onto the VM.
  • Limits. No function timeouts, cold starts, or 4.5 MB request cap. Server Actions still accept 1 MB bodies by default (raise experimental.serverActions.bodySizeLimit for uploads).
  • What you no longer get. A global CDN and edge regions: the app runs on one VM in one region. @vercel/analytics and @vercel/speed-insights only report on Vercel, so remove them.
  • Domains and logs. Domains are a CNAME away (custom domains), and logs are journalctl -u app -f.

ISR, next/image, Server Actions, streaming, after(), and proxy.ts all work with next start without changes.

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-nextjs.exe.xyz:backups ./my-nextjs-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-nextjs.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 daily cron job that deletes guestbook messages older than 90 days", or "Move my database to PostgreSQL on this VM".

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-nextjs is taken):

ssh exe.dev new --name=my-nextjs
ssh my-nextjs.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 24, 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. Next.js 16 needs Node.js 20.9 or later.

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs libjemalloc2 sqlite3 rsync
sudo corepack enable
node --version

Expect v24. and a patch version. corepack enable provides pnpm and yarn for repositories that use them; libjemalloc2 is the memory allocator sharp recommends; 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 the build (NEXT_PUBLIC_*
# values end up in the browser's JavaScript) and by app.service.
# After changing it, redeploy: ~/app.git/hooks/pre-receive HEAD
EOF
fi
stat -c '%a %n' ~/app.env

Expect 600 /home/exedev/app.env. Now the service. It runs next start in ~/app, which will point at the current release, on port 8000, where exe.dev's proxy sends https://my-nextjs.exe.xyz/:

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

[Service]
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
Environment=PORT=8000
# sharp, behind next/image, recommends jemalloc over glibc's allocator.
Environment=LD_PRELOAD=libjemalloc.so.2
ExecStart=/home/exedev/app/node_modules/.bin/next 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. The service runs next start directly, not npm start, so a start script that sets another port doesn't get in the way. If your app has a custom server (a server.js), change ExecStart to /usr/bin/node server.js and have it listen on PORT.

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 nextjs-repo --repository OWNER/NAME --readonly --attach vm:my-nextjs

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  # the build needs devDependencies; next sets NODE_ENV itself
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
if [ -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
migrate=${MIGRATE_COMMAND:-}
if [ -z "$migrate" ] && [ "$(npm pkg get scripts.db:migrate)" != "{}" ]; 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 npm run build and the db:migrate script if package.json 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 if you brought a repository. Otherwise, generate an app with create-next-app's defaults (TypeScript, Tailwind CSS, ESLint, the App Router, and Cache Components):

rm -rf /tmp/new-app && mkdir /tmp/new-app && cd /tmp/new-app
npx --yes create-next-app@latest "$(hostname)" --yes

Add Drizzle with SQLite, and scripts for making and applying migrations:

cd "/tmp/new-app/$(hostname)"
npm install drizzle-orm better-sqlite3
npm install --save-dev drizzle-kit @types/better-sqlite3
npm pkg set 'scripts.db:generate=drizzle-kit generate' 'scripts.db:migrate=drizzle-kit migrate'
printf '\n# SQLite databases (on the VM, data lives in ~/data)\n*.db\n*.db-*\n' >> .gitignore
echo 'DATABASE_URL=file:/home/exedev/data/app.db' >> ~/app.env

The schema, the connection, and Drizzle Kit's configuration:

cd "/tmp/new-app/$(hostname)"
mkdir -p db
cat > db/schema.ts <<'EOF'
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const messages = sqliteTable("messages", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  author: text("author").notNull(),
  body: text("body").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date()),
});
EOF
cat > db/index.ts <<'EOF'
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";

// 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:/, "");
const sqlite = new Database(file);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("busy_timeout = 5000");

export const db = drizzle(sqlite);
EOF
cat > drizzle.config.ts <<'EOF'
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "sqlite",
  schema: "./db/schema.ts",
  out: "./drizzle",
  dbCredentials: { url: process.env.DATABASE_URL ?? "file:app.db" },
});
EOF

The guestbook: a Server Action that saves a message, signed with the visitor's exe.dev name, and a list read from the database on each request. connection() keeps that read out of the build; Cache Components asks for it to sit inside <Suspense>.

cd "/tmp/new-app/$(hostname)"
mkdir -p app/guestbook
cat > app/guestbook/page.tsx <<'EOF'
import { Suspense } from "react";
import { headers } from "next/headers";
import { connection } from "next/server";
import { revalidatePath } from "next/cache";
import { desc } from "drizzle-orm";
import { db } from "@/db";
import { messages } from "@/db/schema";

async function sign(formData: FormData) {
  "use server";
  const body = String(formData.get("body") ?? "").trim().slice(0, 280);
  if (!body) return;
  // exe.dev's proxy tells the app who is signed in.
  const email = (await headers()).get("x-exedev-email");
  const author = email ? email.split("@")[0] : "a visitor";
  db.insert(messages).values({ author, body }).run();
  revalidatePath("/guestbook");
}

async function Messages() {
  await connection(); // read the database on each request, not at build time
  const rows = db.select().from(messages).orderBy(desc(messages.id)).limit(50).all();
  if (rows.length === 0) return <p>No messages yet. Be the first.</p>;
  return (
    <ul className="space-y-2">
      {rows.map((m) => (
        <li key={m.id}>
          <b>{m.author}</b>: {m.body}
        </li>
      ))}
    </ul>
  );
}

export default function GuestbookPage() {
  return (
    <main className="mx-auto max-w-xl space-y-6 p-8">
      <h1 className="text-2xl font-semibold">Guestbook</h1>
      <form action={sign} className="flex gap-2">
        <input name="body" required maxLength={280} placeholder="Say hello"
          className="flex-1 rounded border px-3 py-2" />
        <button className="rounded bg-black px-4 py-2 text-white">Sign</button>
      </form>
      <Suspense fallback={<p>Loading…</p>}>
        <Messages />
      </Suspense>
    </main>
  );
}
EOF

Generate the first migration, commit, and push to the VM's repository. The push deploys:

cd "/tmp/new-app/$(hostname)"
npm run db:generate
git add .gitignore package.json package-lock.json drizzle.config.ts db drizzle app
git -c user.name="$(hostname)" -c user.email="exedev@$(hostname).exe.xyz" commit -qm "Add a guestbook: Drizzle with 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 AUTH_SECRET with openssl rand -base64 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/
cd ~/app && node -p "require(require.resolve('sharp', { paths: [require.resolve('next')] })).versions.sharp"

Expect enabled, active, the release directory, 200 (or your home page's usual status, such as a 307 to a login page), and sharp's version. For a new app, check that the guestbook reads the database:

curl -s http://localhost:8000/guestbook | grep -o 'No messages yet' | head -1
sqlite3 ~/data/app.db .tables

Expect No messages yet and __drizzle_migrations messages.

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-20261017-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. ~/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-20261017-030512.tar.gz my-nextjs.exe.xyz:), then on the VM:

rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf ~/app-nightly-20261017-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 Next.js and Node.js

Next.js is a dependency of your app, so update it on your computer and deploy:

npm install next@latest react@latest react-dom@latest eslint-config-next@latest
npm run build
git commit -am "Update Next.js" && git push exe main

For a new major version, read the upgrade guide first; npx @next/codemod@canary upgrade latest does most of the work.

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 LTS major (Node.js 26 is due to become LTS in late October 2026), take a backup, switch the apt repository, and redeploy right away without restarting first: the running release's native modules, such as better-sqlite3, were built for the old Node.js.

~/bin/app-backup
curl -fsSL https://deb.nodesource.com/setup_26.x | sudo -E bash -
sudo apt-get install -y nodejs
sudo npm install -g corepack && sudo corepack enable
~/app.git/hooks/pre-receive HEAD

Node.js 25 and later no longer include Corepack, hence the npm install line, which keeps pnpm and yarn working.

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, often the same one npm run build gives on your computer. The running version didn't change; fix it and push again.
  • Error: Cannot find module or next: not found during the build or at start. The package manager didn't install everything. Check that your lockfile is committed and up to date (npm install on your computer, then commit package-lock.json).
  • A Yarn 2+ app can't find next. With Plug'n'Play there's no node_modules for app.service to start from; add nodeLinker: node-modules to .yarnrc.yml.
  • The build runs out of memory. Large apps can need more than Node's default heap: add NODE_OPTIONS=--max-old-space-size=4096 to ~/app.env, or give the VM more memory.
  • A new environment variable has no effect. Redeploy with ~/app.git/hooks/pre-receive HEAD; NEXT_PUBLIC_* values are fixed at build time.
  • Invalid Server Actions request. Something between exe.dev and Next.js rewrites X-Forwarded-Host; see Server Actions.
  • Redirects go to localhost:8000. The code builds URLs from request.url; use X-Forwarded-Host or a relative path.
  • SqliteError: 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#

Should I use output standalone or next start?

On a VM, next start. It runs every Next.js feature, needs no change to your config, and serves public/ and .next/static itself. output: 'standalone' traces the files the server needs into .next/standalone (about 50 MB instead of 600) for small Docker images; you then copy public and .next/static in yourself. If your repository already sets it, this recipe still works: next start prints a warning and serves the app.

Do I need a Dockerfile?

No. The VM builds and runs your app with Node.js directly, which keeps .next/cache, SQLite, and uploads on an ordinary disk. If you'd rather ship a container, see Docker on exe.dev.

How big a VM does a Next.js app need?

The default (2 vCPUs, 8 GB of memory, 25 GB of disk) builds and serves a typical app comfortably; a build takes under a minute and the server uses about 120 MB. Each kept release with its node_modules is about 600 MB, and the hook keeps three.

Can I run several Next.js apps on one VM?

Yes, with a second repository, env file, service, and port (say 3001, reachable privately at https://my-nextjs.exe.xyz:3001/). Only one port can be public, so most people give each public app its own VM.

Does next/image work without Vercel?

Yes. next start resizes and converts images with sharp, which npm installs along with Next.js, and caches the results in .next/cache/images. There's nothing to configure and no per-image fee.

Why does my app say "Invalid Server Actions request"?

The browser's Origin didn't match the host Next.js saw. Through exe.dev's proxy they match, so look for something in between that rewrites X-Forwarded-Host, or a form posted from another site. See Server Actions.

Updated Oct 17, 2026 · Markdown · manifest.json · Something wrong? Tell us