slew docs/runtimes/node-express

Node servers

A deployment can carry a server: a Node app that runs on slew's sandboxed runner nodes behind https://<project>.slew.cloud. This page is the raw contract, shown with a plain Express app. The Astro, SvelteKit, and Remix recipes apply the same contract per framework — and Next.js needs none of this: a standalone Next build is detected and assembled automatically on git builds.

The output contract

A deployment becomes a server deployment when the directory you upload has a slew.json at its root pointing at a server entry:

slew.json     the manifest, below
server/       your server bundle — the entry file plus everything it imports
static/       optional: files the router serves directly from the CDN
{ "server": "server/index.mjs", "runtime": "node22" }
Field Rules
server Path of the entry file. Must start with server/ and exist in the upload. null — or no slew.json at all — means a plain static deployment.
runtime Optional. node22 is the only runtime, and the default.

Those are the only two fields; anything else in the file is ignored. A manifest that breaks the rules fails the upload with 400 invalid_archive.

How your server runs

  • Started as node <server-entry> — for the manifest above, node server/index.mjs — with the artifact root as its working directory.
  • PORT and HOSTNAME are injected by the platform (PORT=3000, HOSTNAME=0.0.0.0 today) and always win — project env vars can't override them. Listen on process.env.PORT and bind 0.0.0.0.
  • NODE_ENV=production is set as an overridable default; runtime-scoped project env vars are injected at start.
  • The bundle is mounted read-only; anything the process writes elsewhere on disk is ephemeral and gone after a restart.
  • A new instance takes traffic only once it answers on its port (30-second limit), so a deploy that fails to boot never receives a request.
  • Idle servers scale to zero; the next request wakes one (a cold start). slew logs tails the live instance.

Static files

For a server deployment, GET requests whose path has a file extension (/app-h4sh123.js, /logo.svg) are served from static/ — CDN-cached under the usual rules, your server never sees them. Everything else — extensionless paths, misses in static/, all non-GET requests — goes to your app. There is no SPA fallback: the server owns its routing and its 404s.

A minimal Express app

server.mjs:

import express from 'express'

const app = express()
app.get('/', (_req, res) => res.send('hello from slew'))
app.listen(process.env.PORT ?? 3000, '0.0.0.0')

slew deploy never packs node_modules — any path containing a node_modules component is excluded (see how deploy works) — so for CLI deploys the entry must be self-contained. Bundle it:

npm i -D esbuild
npx esbuild server.mjs --bundle --platform=node --format=esm --outfile=out/server/index.mjs
printf '{ "server": "server/index.mjs" }\n' > out/slew.json
cp -r public out/static        # optional: anything the CDN should serve directly

Deploy:

slew init
slew deploy out

Git push-to-deploy

Put the assembly steps in a script (an npm script or scripts/build-slew.sh) and connect the repo with the output directory pointing at the artifact:

slew git connect owner/repo --build-cmd "npm run build:slew" --output out

Every push then builds the artifact in an isolated container and deploys it. Unlike the CLI, git builds tar the output directory as-is — a node_modules directory inside the artifact does ship, which is how the framework recipes carry dependencies that can't be bundled. Keep it pruned (npm ci --omit=dev): the usual deploy limits apply, including the 10,000-file cap.