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

On this page

To self-host Next.js on a VPS with ox, add the repo as a project and deploy. ox reads package.json and the lockfile to find your install, next build and next start, 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.

ox reads the ox.toml on this page, makes the plan on the card, and starts npm run start on your server. The cylinder is the service the file asks for: postgres.

What ox detects in a Next.js repo

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

  • The Node version, from devEngines.runtime or engines.node in package.json, or from .nvmrc, .node-version or mise.toml. With none of them, ox uses Node 24. A range gets Node 24 when it allows it: an engines.node of >=22 installs Node 24, and an engines.node of ^20 installs Node 20. Once your app is live, it keeps its Node version until you change the range to exclude it.
  • The install step from the lockfile: npm ci --include=dev for package-lock.json, pnpm install --frozen-lockfile (with --prod=false before pnpm 10), yarn install --frozen-lockfile --production=false (Yarn 1) or bun install --frozen-lockfile. These flags install the devDependencies your build needs, even when you set NODE_ENV=production.
  • The build: npm run build when there is a build script, else npx next build. ox uses your package manager in place of npm.
  • The start: npm run start when there is a start script that serves on the port ox gives. ox runs Next.js itself when there is no script, when the script runs next dev (a development server), or when it fixes its own port (it would never get $PORT). So a script of next start -p 3000 runs npx next start -H 127.0.0.1 -p $PORT, and a script of next start runs npm run start.
  • A static site in out when next.config.* sets output: 'export', with no app process.
  • The migrate step npx prisma migrate deploy, when there is a prisma/schema.prisma.
  • Services, 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. In a monorepo, the dependencies of each workspace member count too (npm or yarn workspaces, or pnpm-workspace.yaml). A member's devDependencies do not. If your app is live and you set DATABASE_URL yourself before ox found the driver, ox keeps using yours and runs no PostgreSQL.

next start reads PORT by itself, so the usual scripts work as they are. ox does not guess a health path. Without one, it only checks that something accepts a connection on the port. 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 --include=dev"
commands = ["npm run build"]

[services]
postgres = {}
  • domains: the names your app answers on. ox gets an HTTPS certificate for each.
  • start: the command that runs your app. ox detects the same one, 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 what ox detects.
  • postgres: a database for this app. Leave it out if you do not use one.

Each deploy starts a new release from the commit, so files the app writes in the release are gone after the next deploy. Next.js saves optimized images under .next/cache. If you use next/image, add the lines below ([storage] keep). ox then 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 small 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 = {} to 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 run ox vars set <project> AUTH_SECRET, which asks for the value.

Some keys in .env.example need your answer: an empty value, a secret, or a placeholder. Each of those must be set before a deploy can start. A key with a real example value keeps the app's default.

Your variables are also set during the build. 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, PUBLIC_SCHEME, PUBLIC_PORT, 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 a custom server (a server.js your start script runs) listens on process.env.PORT.
  • 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. Troubleshooting has more causes and fixes, and the config reference lists every key.

Next steps

New to running your own server? How ox runs your apps gives the overview, and the blog's deploy guides walk through a setup by hand.