# Deploy a Node.js or Express API to your own VPS Deploy an Express, Fastify or other Node.js API to your own VPS: start script, lockfile install, migrations, PostgreSQL and Redis, from one short ox.toml. To deploy a Node.js API such as Express to a VPS with ox, give `package.json` a `start` script, listen on `PORT` and `HOST`, and deploy. ox detects the Node version, the install step from your lockfile, your build script and your migrate tool. It runs the server as a systemd service behind Caddy, with no Docker or PM2. PostgreSQL and Redis come from the `[services]` table of the ox.toml below. ox reads the ox.toml on this page, makes the plan on the card, and starts `npm run start` on your server. The cylinders are the services the file asks for: `postgres` and `redis`. ## What ox detects in a Node.js repo 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, else the newest older version it allows: an `engines.node` of `>=18` installs Node 24, and an `engines.node` of `>=18 <21` 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`. With no lockfile, ox uses an install that does not freeze versions: `npm install --include=dev`, `pnpm install --no-frozen-lockfile`, `yarn install` or `bun install`. - The build: `npm run build` when there is a `build` script, such as a TypeScript compile. ox uses your package manager in place of npm. - The start: `npm run start` when there is a `start` script, so a script of `node src/server.js` runs `npm run start`. A script that fixes its own port, such as `PORT=3000 node server.js`, never gets the port ox gives. ox does not run it, and `ox check` says why in a hint. - With no `start` script, the start is `node `. ox does this only when a web framework it knows is a dependency: Express, Fastify, Koa, Hapi, Restify, Polka or Hono's Node server. The file is the `main` field of `package.json` when that file is in the repo. Otherwise it is the one `index.js`, `server.js` or `app.js` at the root. - The migrate step `npx drizzle-kit migrate` with a `drizzle.config.*`, `npx knex migrate:latest` with a `knexfile.*`, or `npx prisma migrate deploy` with `prisma/schema.prisma` (or `schema.prisma` beside a package.json), when the tool is a dependency. In a monorepo, the step runs from the folder of the workspace member that holds the schema. - Services, when the repo has no ox.toml: PostgreSQL when `pg`, `postgres` or `@neondatabase/serverless` is a dependency, 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. Your app must listen on the port and address ox gives it in `PORT` and `HOST`. A `start` script is the surest way to name your server. Without one, ox may not tell which file is the server, such as when `main` points into a `dist` folder your build makes. Then it proposes nothing, and `ox check` says why in a hint. src/server.js: ```javascript import express from "express"; const app = express(); app.get("/health", (req, res) => res.send("ok")); app.listen(process.env.PORT, process.env.HOST); ``` package.json, the scripts ox uses: ```json { "type": "module", "engines": { "node": "24" }, "scripts": { "start": "node src/server.js", "migrate": "node src/migrate.js" } } ``` ## The ox.toml for an Express API ox.toml for an Express API with PostgreSQL and Redis: ```toml domains = ["api.example.com"] [app] start = "npm run start" health = "/health" [build] install = "npm ci --include=dev" migrate = "npm run migrate" [services] postgres = {} redis = {} ``` - `domains`: the names your API answers on. ox gets an HTTPS certificate for each. - `start`: the command that runs your server. 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`: installs your packages from `package-lock.json`. - `migrate`: your own migration script, run on every deploy after ox takes a snapshot of the database. Leave it out if you have none. - `postgres` and `redis`: a database and a Redis for this app. Keep only the ones you use. ## PostgreSQL and Redis for a Node API ox gives your app `DATABASE_URL` for `postgres` and `REDIS_URL` for `redis` ([[services]](https://console.t12.sorv.dev/docs/config#services)). Both `pg` and `ioredis` take the URL as it is. Once a repo has an ox.toml, ox runs only the services it lists. src/db.js: ```javascript import pg from "pg"; import Redis from "ioredis"; export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL }); export const redis = new Redis(process.env.REDIS_URL); ``` ## Variables and secrets ox also gives you `PUBLIC_URL` and `PUBLIC_HOST`. Set your own keys, like `SESSION_SECRET`, on the dashboard, or run `ox vars set SESSION_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. ox does not set `NODE_ENV`. If your code checks it, set it to `production` yourself. ## Check and deploy the Node API 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, REDIS_URL Set on the dashboard before the first deploy: SESSION_SECRET Ready to deploy. ``` ## If the Node deploy fails - `nothing to run: ox found no start command, static site, worker, or cron job; set [app] start (or declare [workers])`. `ox check` says this when `package.json` has no `start` script and ox could not pick the server file. The hint above it says why. Add a `start` script, or set `[app] start`. - `GET http://127.0.0.1:/health did not answer within 120s`. The app listens on a fixed port like 3000 instead of `process.env.PORT`, or `/health` returns a 404. Listen on the port ox gives you, and check the path. - `set these before deploying: SESSION_SECRET`. A key from `.env.example` has no value yet. Set it, then deploy again. 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 SESSION_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. - [See the daily database backups](https://console.t12.sorv.dev/docs/operate/backups) and restore one. Last updated 2026-10-08. The page as HTML: https://console.t12.sorv.dev/docs/guides/node