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
On this page
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.
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.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, else the newest older version it allows: anengines.nodeof>=18installs Node 24, and anengines.nodeof>=18 <21installs 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. With no lockfile, ox uses an install that does not freeze versions:npm install --include=dev,pnpm install --no-frozen-lockfile,yarn installorbun install. - The build:
npm run buildwhen there is abuildscript, such as a TypeScript compile. ox uses your package manager in place of npm. - The start:
npm run startwhen there is astartscript, so a script ofnode src/server.jsrunsnpm run start. A script that fixes its own port, such asPORT=3000 node server.js, never gets the port ox gives. ox does not run it, andox checksays why in a hint. - With no
startscript, the start isnode <file>. 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 themainfield ofpackage.jsonwhen that file is in the repo. Otherwise it is 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(orschema.prismabeside 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,postgresor@neondatabase/serverlessis a dependency, 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.
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.
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 --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 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 run ox vars set <project> 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
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, 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. Listen on the port ox gives you, and check the path.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. Troubleshooting has more causes and fixes, and the config reference lists every key.
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.
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.