# 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 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: ```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 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](https://console.t12.sorv.dev/docs/config#tools)). ox then 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 small 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 = {}` 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 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: ```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. [Troubleshooting](https://console.t12.sorv.dev/docs/troubleshooting) has more causes and fixes, and the [config reference](https://console.t12.sorv.dev/docs/config) lists every key. ## Next steps - [Set AUTH_SECRET and other variables](https://console.t12.sorv.dev/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://console.t12.sorv.dev/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://console.t12.sorv.dev/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://console.t12.sorv.dev/docs/operate/rollback) to a kept release without a rebuild. - [Turn on a preview per branch](https://console.t12.sorv.dev/docs/operate/previews), each with its own database. Last updated 2026-10-08. The page as HTML: https://console.t12.sorv.dev/docs/guides/nextjs