Guides · Deployment

Node.js hosting: deploy an Express or Fastify app

What an Express, Fastify or plain Node.js server needs to run in production, and what Staxa does with your repository.

By Jonathan Pitter ·

The short answer

Any host that runs containers can run a Node.js server, as long as the app follows four rules:

  1. Listen on process.env.PORT, on 0.0.0.0.
  2. Read secrets and the database URL from environment variables, never from a committed .env.
  3. Commit the lockfile (package-lock.json, pnpm-lock.yaml or yarn.lock), so production installs exactly what you tested.
  4. Handle SIGTERM, so a deploy finishes in-flight requests instead of dropping them.

This is a complete Express server that does all four. We ran it in a container against PostgreSQL 16:

const express = require("express")
const { Pool } = require("pg")

const app = express()
// Staxa sets DATABASE_URL when the tenant has a database
const pool = new Pool({ connectionString: process.env.DATABASE_URL })

app.get("/", async (req, res) => {
  const { rows } = await pool.query("select now() as now")
  res.json({ now: rows[0].now })
})

app.get("/healthz", (req, res) => res.send("ok"))

const port = Number(process.env.PORT) || 3000
const server = app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`))

// Kubernetes sends SIGTERM before stopping a pod: finish in-flight requests
process.on("SIGTERM", () => {
  server.close(() => pool.end().then(() => process.exit(0)))
})

Listen on PORT and 0.0.0.0

Platforms assign the port and route traffic to the container's network interface. app.listen(port, "0.0.0.0") accepts it; an app hard-coded to 3000, or bound to localhost, starts and never receives a request. Fastify is the one to watch: fastify.listen({ port }) binds to localhost by default, so pass the host:

fastify.listen({ port: Number(process.env.PORT) || 3000, host: "0.0.0.0" })

Configuration and the database

Read configuration from process.env at startup. pg takes a connection string directly, as above, and so do Prisma (DATABASE_URL in schema.prisma), Drizzle and Knex.

On Staxa, a tenant with a database gets DATABASE_URL plus DB_HOST, DB_PORT, DB_NAME, DB_USER and DB_PASSWORD. The database sits inside the tenant's private network, so the URL ends in sslmode=disable and needs no certificate setup.

Run migrations before the server starts serving. With Prisma that's prisma migrate deploy, with Knex knex migrate:latest, and the simplest place for it is the start script: "start": "prisma migrate deploy && node server.js". Production images usually drop development dependencies, and Prisma's CLI is installed as one by default, so move prisma to dependencies if the start script runs it.

Production settings

  • NODE_ENV=production. Express turns on view caching and less verbose errors, and many libraries skip dev-only work.
  • Development dependencies out of the image. Install everything to build, then prune (npm prune --omit=dev) or install with npm ci --omit=dev in the runtime stage.
  • A process manager isn't needed. The platform restarts a crashed container. PM2's cluster mode only helps if one container should use several CPU cores; running more replicas does the same thing more simply.
  • Logs go to stdout. Write JSON lines with pino or plain console.log; the platform collects them.

Deploying Node.js on Staxa

Connect the GitHub repository. Staxa detects Express or Fastify from package.json (and Next.js, Nuxt, SvelteKit, Remix and NestJS from their own config files), and if there's no Dockerfile it generates one. This is the one for Express on port 3000 with Node.js 24 selected:

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile; \
    elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
    else npm ci; fi
COPY . .
RUN if grep -q '"build"' package.json 2>/dev/null; then npm run build; fi

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 app
COPY --from=build --chown=app:nodejs /app .
RUN npm prune --omit=dev 2>/dev/null || true
USER app
ENV PORT=3000
EXPOSE 3000
# Shell form: the start command is a shell snippet (npm start when package.json
# has a start script, otherwise node on its "main" file).
CMD if node -e 'process.exit(require("./package.json").scripts?.start ? 0 : 1)'; then exec npm start; else exec node "$(node -p 'require("./package.json").main || "index.js"')"; fi

In practice:

  • The lockfile picks the package manager. pnpm and Yarn lockfiles are installed with a frozen install; otherwise npm ci, which fails without package-lock.json.
  • A build script runs. TypeScript compiled with tsc to dist/ works, as long as start runs the compiled output.
  • npm start starts the app if package.json has a start script. If not, Staxa runs node on main, and npm init sets main to index.js.
  • PORT is set to the service's port, and https://<service>-<tenant>.staxa.app routes to it with TLS.
  • Readiness is GET / returning a 2xx or 3xx unless you set a health check path. An API with no root route should set /healthz.

If your repository has a Dockerfile, Staxa builds that instead. The Fastify and NestJS PostgreSQL demos each have one, and Next.js has its own guide.

Choosing a Node.js host

OptionGood forWatch out for
A VPS with PM2 and nginxFull control, lowest costYou patch the OS, renew TLS and run the database
Serverless functionsSpiky, low trafficNo long-lived connections; connection pooling for Postgres
A container platformMost web apps and APIsPer-service pricing as you add workers and databases
StaxaOne app per customer, each with its own database and subdomainBuilt for isolated tenants rather than one big app

Express and Fastify are long-running servers. They run best where a process stays up and holds a database pool, which rules out most serverless setups without changes. See the Heroku alternatives comparison for prices and trade-offs.

Common problems

The deploy never becomes ready

The app listens on a fixed port, on localhost, or GET / returns 404. Use PORT and 0.0.0.0, and set the health check path to a route that returns 200.

npm ci fails

npm ci needs a package-lock.json that matches package.json. Run npm install locally and commit the lockfile.

Cannot find module 'dist/server.js'

The start script runs compiled output that was never built. Add a build script ("build": "tsc") so it runs during the image build.

Every deploy drops requests

Nothing handles SIGTERM, or the start command wraps node in a shell that doesn't forward it. Add the handler above, and start the process with exec (or node directly) in your own Dockerfile.

Frequently asked questions

Which Node.js version should I use?

An active LTS release. Node.js 24 and 26 are active LTS releases, 22 is in maintenance, and Node.js 20 reached end of life in April 2026. Pin it with engines.node in package.json and pick the same version in your host.

Do I need a build step for Express?

Only for TypeScript or a bundler. Plain JavaScript runs as it is.

Can I run a worker next to the web server?

Run it as a second service with its own start command, rather than a second process in the same container. On Staxa that's a second service in the tenant from the same repository, pointed at its own Dockerfile (each service has its own Dockerfile path), since the generated one always starts the web server.

Give every customer their own stack

Staxa provisions an isolated app, database, subdomain and TLS per customer from one API call. The Free plan includes 2 tenants.