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 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.
npm run start on your server. The cylinder is the service the file asks for: postgres.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
devEngines.runtimeorengines.nodeinpackage.json, or from.nvmrc,.node-versionormise.toml. With none of them, Node 24. A range takes Node 24 when it allows it, so anengines.nodeof>=22installs Node 24, and anengines.nodeof^20installs Node 20. Once your app is live, it keeps its Node version until you change the range so it no longer allows it. - The install step from the lockfile:
npm ci --include=devforpackage-lock.json,pnpm install --frozen-lockfile(with--prod=falsebefore pnpm 10),yarn install --frozen-lockfile --production=false(Yarn 1) orbun install --frozen-lockfile. The flags keep thedevDependenciesyour build needs when you setNODE_ENV=production. npm run buildwhen there is abuildscript, elsenpx next build(with your package manager in place of npm).npm run startwhen there is astartscript that serves on the port ox gives. A script ofnext devis a development server, and one that fixes its own port never gets$PORT, so for either, and with no script, ox runs Next.js itself: a script ofnext start -p 3000runsnpx next start -H 127.0.0.1 -p $PORT, and a script ofnext startrunsnpm run start.- 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. In a monorepo, thedependenciesof each workspace member count too (npm or yarnworkspaces, orpnpm-workspace.yaml), but not a member'sdevDependencies. Once your app is live, if you setDATABASE_URLyourself before ox found the driver, ox keeps using yours and runs no PostgreSQL.
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 --include=dev"
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.
Each deploy starts a new release from the commit, so what the app writes there is gone on the next deploy. 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 in .env.example that needs your answer (empty, a secret, or a placeholder) must be set before a deploy can start; a key with a real example value keeps the app's default.
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, 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.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 a custom server (aserver.jsyour start script runs) listens onprocess.env.PORT.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.
New to running your own server? How ox self-hosts your apps gives the overview, and the blog's deploy guides walk through a setup by hand.