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.
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.runtimeorengines.nodeinpackage.json, or from.nvmrc,.node-versionormise.toml. With none of them, ox uses Node 24. A range gets Node 24 when it allows it: 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 to exclude 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. These flags install thedevDependenciesyour build needs, even when you setNODE_ENV=production. - The build:
npm run buildwhen there is abuildscript, elsenpx next build. ox uses your package manager in place of npm. - The start:
npm run startwhen there is astartscript that serves on the port ox gives. ox runs Next.js itself when there is no script, when the script runsnext dev(a development server), or when it fixes its own port (it would never get$PORT). So 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. - The migrate step
npx prisma migrate deploy, when there is aprisma/schema.prisma. - Services, 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). A member'sdevDependenciesdo not. If your app is live and you setDATABASE_URLyourself 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.
{
"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 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.installandcommands: 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.
[storage]
keep = [".next/cache"]The health path can be a small 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 = {} 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
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. Troubleshooting has more causes and fixes, and the config reference lists every key.
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 runs your apps gives the overview, and the blog's deploy guides walk through a setup by hand.