# 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. 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. 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 ox reads `package.json` and your lockfile. When `next` is a dependency, it proposes from the repo root: - The Node version from `devEngines.runtime` or `engines.node` in `package.json`, or from `.nvmrc`, `.node-version` or `mise.toml`. With none of them, Node 24. A range takes Node 24 when it allows it, so 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 so it no longer allows 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`. The flags keep the `devDependencies` your build needs when you set `NODE_ENV=production`. - `npm run build` when there is a `build` script, else `npx next build` (with your package manager in place of npm). - `npm run start` when there is a `start` script that serves on the port ox gives. A script of `next dev` is 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 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. - `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. In a monorepo, the `dependencies` of each workspace member count too (npm or yarn `workspaces`, or `pnpm-workspace.yaml`), but not a member's `devDependencies`. Once your app is live, if you set `DATABASE_URL` yourself 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. package.json, the scripts ox uses: ```json { "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: ```toml 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. - `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. 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](https://console.t74.sorv.dev/docs/config#tools)), and ox links that folder to a writable one that survives deploys. Add to ox.toml when you use next/image: ```toml [storage] keep = [".next/cache"] ``` The health path can be a tiny route handler that does not touch the database. app/healthz/route.js: ```javascript 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 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 Check, deploy, and watch: ```sh ox check # in the repo: prints the plan and "Ready to deploy." ox deploy --wait # stream the deploy, exit with its result ox logs --follow # the app's own logs ``` For the ox.toml above, `ox check` ends like this: ox check, last lines: ```text 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:/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. More causes and fixes are in [Troubleshooting](https://console.t74.sorv.dev/docs/troubleshooting), and every key is in the [config reference](https://console.t74.sorv.dev/docs/config). ## Next steps - [Set AUTH_SECRET and other variables](https://console.t74.sorv.dev/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://console.t74.sorv.dev/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://console.t74.sorv.dev/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://console.t74.sorv.dev/docs/operate/rollback) to a kept release without a rebuild. - [Turn on a preview per branch](https://console.t74.sorv.dev/docs/operate/previews), each with its own database. Last updated 2026-10-08. The page as HTML: https://console.t74.sorv.dev/docs/guides/nextjs