Docs menuDeploy Next.js

Deploy Next.js to a VPS: self-host without Vercel

Self-host Next.js on your own VPS without Vercel or Docker: next start, a health check, PostgreSQL, Prisma migrations and the image cache, in one ox.toml.

Last updated 2026-10-08

To self-host Next.js on a VPS with ox, add the repo as a project and deploy: ox detects your install, next build and next start from package.json and the lockfile, so most apps need no ox.toml. next start runs as a systemd service behind Caddy, with no Vercel, Docker or PM2. Add the short ox.toml below for your own domain, a real health check and PostgreSQL.

What ox detects in a Next.js repo

ox reads package.json and your lockfile. When next is a dependency, it proposes from the repo root:

  • The Node version from engines.node in package.json, or from .nvmrc, .node-version or mise.toml. With none of them, Node 24.
  • The install step from the lockfile: npm ci for package-lock.json, pnpm install --frozen-lockfile, yarn install --frozen-lockfile or bun install --frozen-lockfile.
  • npm run build when there is a build script, and npm run start when there is a start script (with your package manager in place of npm). Without the scripts, npx next build and npx next start -H 127.0.0.1 -p $PORT.
  • A static site in out when next.config.* sets output: 'export', with no app process.
  • npx prisma migrate deploy as the migrate step when there is a prisma/schema.prisma.
  • When the repo has no ox.toml: PostgreSQL when pg, postgres or @neondatabase/serverless is a dependency (or the Prisma schema uses postgresql), and Redis when redis, ioredis or bullmq is.

ox does not guess a health path. Without one it only checks that something accepts a connection on the port. next start reads PORT by itself, so the usual scripts work as they are. With output: 'standalone', ox still runs next start and ox check prints a hint on how to run the standalone server instead.

package.json, the scripts ox uses
{
  "engines": { "node": ">=22" },
  "scripts": {
    "build": "next build",
    "start": "next start"
  }
}

The ox.toml for Next.js

You can deploy with no ox.toml. Add this one when you want your own domain and a real health check.

ox.toml for a Next.js app with PostgreSQL
domains = ["app.example.com"]

[app]
start  = "npm run start"
health = "/healthz"

[build]
install  = "npm ci"
commands = ["npm run build"]

[services]
postgres = {}
  • domains: the names your app answers on. ox gets the HTTPS certificate for each.
  • start: the command that runs your app. It is the same one ox detects, so you can leave it out.
  • health: a path that must answer with a 2xx or 3xx status before a new release gets traffic.
  • install and commands: install your packages, then build. These also match detection.
  • postgres: a database for this app. Leave it out if you do not use one.

A release's files are read-only while it runs. Next.js saves optimized images under .next/cache, so if you use next/image, add this ([storage] keep), and ox links that folder to a writable one that survives deploys.

Add to ox.toml when you use next/image
[storage]
keep = [".next/cache"]

The health path can be a tiny route handler that does not touch the database.

app/healthz/route.js
export function GET() {
  return new Response("ok");
}

PostgreSQL and Prisma migrations

With postgres = {}, ox creates a database and gives your app DATABASE_URL. Add redis = {} and you also get REDIS_URL. Once a repo has an ox.toml, ox runs only the services it lists. If you use Prisma, every deploy takes a snapshot of the database and then runs npx prisma migrate deploy.

Variables, secrets and NEXT_PUBLIC_ values

ox also gives you PORT, HOST, PUBLIC_URL and PUBLIC_HOST. Set your own keys, like AUTH_SECRET, on the dashboard or with ox vars set <project> AUTH_SECRET, which asks for the value. Every key named in .env.example must be set before a deploy can start.

Your variables are there during the build too. Next.js bakes NEXT_PUBLIC_ values into the build, so a change to one needs a new deploy. ox vars set deploys again right away, unless you add --no-deploy.

Check and deploy the Next.js app

Check, deploy, and watch
ox check                      # in the repo: prints the plan and "Ready to deploy."
ox deploy <project> --wait    # stream the deploy, exit with its result
ox logs <project> --follow    # the app's own logs

For the ox.toml above, ox check ends like this:

ox check, last lines
  Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, DATABASE_URL
  Set on the dashboard before the first deploy: AUTH_SECRET

Ready to deploy.

If the Next.js deploy fails

  • set these before deploying: AUTH_SECRET. A key from .env.example has no value yet. Set it, then deploy again.
  • GET http://127.0.0.1:<port>/healthz did not answer within 120s. The health path returned an error or a 404, or the app is not listening on $PORT. Check that the route exists and that your start script does not pass a fixed -p.
  • build 1/1: npm run build: … with fix: the command's own output is above; fix it in the repo and push. next build failed, often on a type or lint error. Run npm run build on your machine, fix what it prints, and push.

If an older release is live, it keeps serving while you fix any of these. More causes and fixes are in Troubleshooting, and every key is in the config reference.

Next steps