---
title: "Deploy Next.js on your own server"
description: "Run a Next.js app on an exe.dev VM: git push to deploy, HTTPS, Server Actions, ISR and image optimization, SQLite on a persistent disk, nightly backups."
url: https://exe-staging.dev/deploy/nextjs
category: "Frameworks"
updated: 2026-10-17
---

# Deploy Next.js on your own server

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

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

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

Running on the VM:

- **Next.js 16**: next start, as app.service under systemd, port 8000, data in `~/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 in `~/data`
- **Nightly backup**: app-backup.timer; data, settings, and the repository, data in `~/backups`

How it is reached:

- https://my-nextjs.exe.xyz/ (private): Your app, until you make it public

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Next.js%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Fnextjs.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%20Next.js%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-nextjs.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-nextjs.%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%2024%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%20build%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%20create-next-app%20project%20whose%20%2Fguestbook%20page%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%20a%20build%20fails%2C%20read%20its%20output%2C%0Afix%20what%20you%20can%20without%20editing%20the%20code%2C%20and%20redeploy%20with%0A~%2Fapp.git%2Fhooks%2Fpre-receive%20HEAD.%0AFinish%20with%20the%20app%27s%20URL%20%28and%20%2Fguestbook%20for%20a%20new%20app%29%20and%20the%20git%0Acommands%20the%20owner%20runs%20on%20their%20own%20computer%2C%20from%20%22Deploy%20with%20git%0Apush%22%3A%20%60git%20clone%20-o%20exe%60%20for%20a%20new%20app%2C%20%60git%20remote%20add%20exe%60%20for%20a%0AGitHub%20repository%2C%20then%20%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/nextjs/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

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](#coming-from-vercel).

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

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

```bash
git remote add exe my-nextjs.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 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](/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 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:

```bash
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](https://orm.drizzle.team), 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**:

```bash
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](/deploy/postgres#run-it-next-to-your-app). 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](/docs/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`:

```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](#let-exedev-handle-sign-in)
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](/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-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](/docs/cnames)). 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](/docs/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:

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

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

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

```bash
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](/deploy/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](#cron-jobs). 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](/docs/cnames)),
  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**:

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

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

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

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

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

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

```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 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](/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  # 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](#deploy-and-check) 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):

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

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

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

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

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

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

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

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

```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-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](#create-the-vm) 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:

```bash
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](#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 Next.js and Node.js</summary>

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

```bash
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](https://nextjs.org/docs/app/guides/upgrading) 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:

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

```bash
~/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.

</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, 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](#server-actions-just-work).
- **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.

</details>

## 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](/docs/faq/docker).

### 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](#server-actions-just-work).


## Related

- [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.
- [Run Claude Code on a remote server](https://exe-staging.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [Deploy a Django app](https://exe-staging.dev/deploy/django.md): Your Django app on a computer of its own. git push to deploy.
- [Deploy a Docker Compose app from GitHub](https://exe-staging.dev/deploy/docker-compose.md): Your compose.yaml on a VM of its own. git push to deploy.
