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.
Last updated 2026-10-08
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 lockfile install, your build script and the migrate tool, then 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.
What ox detects in a Node.js repo
ox reads package.json and your lockfile. From the repo root it proposes:
- The Node version from
engines.nodeinpackage.json, or from.nvmrc,.node-versionormise.toml. With none of them, Node 24. - The install step from the lockfile:
npm ciforpackage-lock.json,pnpm install --frozen-lockfile,yarn install --frozen-lockfileorbun install --frozen-lockfile. With no lockfile,npm install. npm run buildwhen there is abuildscript, such as a TypeScript compile, andnpm run startwhen there is astartscript (with your package manager in place of npm).- With no
startscript,node <file>, but only when a web framework ox knows (Express, Fastify, Koa, Hapi, Restify, Polka or Hono's Node server) is a dependency. The file is themainfield ofpackage.jsonwhen that file is in the repo, or else the oneindex.js,server.jsorapp.jsat the root. - The migrate step
npx drizzle-kit migratewith adrizzle.config.*,npx knex migrate:latestwith aknexfile.*, ornpx prisma migrate deploywithprisma/schema.prisma, when the tool is a dependency. - When the repo has no ox.toml: PostgreSQL when
pg,postgresor@neondatabase/serverlessis a dependency, and Redis whenredis,ioredisorbullmqis.
A start script is the surest way: when ox cannot tell which file is the server, such as a main that points into a dist folder your build makes, it proposes nothing and ox check says why in a hint. Your app must listen on the port and address ox gives it in PORT and HOST.
import express from "express";
const app = express();
app.get("/health", (req, res) => res.send("ok"));
app.listen(process.env.PORT, process.env.HOST);{
"type": "module",
"engines": { "node": "24" },
"scripts": {
"start": "node src/server.js",
"migrate": "node src/migrate.js"
}
}The ox.toml for an Express API
domains = ["api.example.com"]
[app]
start = "npm run start"
health = "/health"
[build]
install = "npm ci"
migrate = "npm run migrate"
[services]
postgres = {}
redis = {}domains: the names your API answers on. ox gets the HTTPS certificate for each.start: the command that runs your server. 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: installs your packages frompackage-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.postgresandredis: 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]). Both pg and ioredis take the URL as it is. Once a repo has an ox.toml, ox runs only the services it lists.
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 with ox vars set <project> SESSION_SECRET, which asks for the value. Every key named in .env.example must be set before a deploy can start. ox does not set NODE_ENV for you, so set it to production yourself if your code checks it.
Check and deploy the Node API
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, 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 checksays this whenpackage.jsonhas nostartscript and ox could not pick the server file; the hint above it says why. Add astartscript, or set[app] start.GET http://127.0.0.1:<port>/health did not answer within 120s. The app listens on a fixed port like 3000 instead ofprocess.env.PORT, or/healthreturns a 404. Use the port ox gives you.set these before deploying: SESSION_SECRET. A key from.env.examplehas no value yet. Set it, then deploy again.
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 SESSION_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.
- See the daily database backups and restore one.