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.nodeinpackage.json, or from.nvmrc,.node-versionormise.toml. With none of them, Node 24. - The install step from the lockfile:
npm ciforpackage-lock.json,pnpm install --frozen-lockfile,yarn install --frozen-lockfileorbun install --frozen-lockfile. npm run buildwhen there is abuildscript, andnpm run startwhen there is astartscript (with your package manager in place of npm). Without the scripts,npx next buildandnpx next start -H 127.0.0.1 -p $PORT.- A static site in
outwhennext.config.*setsoutput: 'export', with no app process. npx prisma migrate deployas the migrate step when there is aprisma/schema.prisma.- When the repo has no ox.toml: PostgreSQL when
pg,postgresor@neondatabase/serverlessis a dependency (or the Prisma schema usespostgresql), and Redis whenredis,ioredisorbullmqis.
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.
{
"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.
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.installandcommands: 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.
[storage]
keep = [".next/cache"]The health path can be a tiny route handler that does not touch the database.
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
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 logsFor the ox.toml above, ox check ends like this:
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.examplehas 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: …withfix: the command's own output is above; fix it in the repo and push.next buildfailed, often on a type or lint error. Runnpm run buildon 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
- Set AUTH_SECRET and other variables from the dashboard or the CLI.
- Add your own domain with HTTPS: one A record, and Caddy gets the certificate.
- Read and search the app's logs, live or for a time range.
- Roll back a bad deploy to a kept release without a rebuild.
- Turn on a preview per branch, each with its own database.