Docs menuDeploy a Node API

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.node in package.json, or from .nvmrc, .node-version or mise.toml. With none of them, Node 24.
  • The install step from the lockfile: npm ci for package-lock.json, pnpm install --frozen-lockfile, yarn install --frozen-lockfile or bun install --frozen-lockfile. With no lockfile, npm install.
  • npm run build when there is a build script, such as a TypeScript compile, and npm run start when there is a start script (with your package manager in place of npm).
  • With no start script, 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 the main field of package.json when that file is in the repo, or else 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, when the tool is a dependency.
  • 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.

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.

src/server.js
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
{
  "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
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 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]). 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
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

Check, deploy, and watch
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 logs

For the ox.toml above, ox check ends like this:

ox check, last lines
  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 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:<port>/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. Use the port ox gives you.
  • 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. More causes and fixes are in Troubleshooting, and every key is in the config reference.

Next steps