Deployment
Run a Suku app on a VPS, in Docker, on Cloudflare Workers, or on Vercel.
Suku's kernel is a Fetch Request → Response function, and there are two ways to host it.
- A long-lived process. On a VPS or in Docker you call
app.listen()with the Node or Bun adapter. - A platform that calls you. On Cloudflare Workers and Vercel you pass each request to
app.handleand never listen.
| Host | How Suku runs | Use when |
|---|---|---|
| VPS + PM2 | listen() under a process manager | A simple always-on API, several Node workers |
| VPS + systemd | listen() under a unit file | No extra runtime, tight control of the OS |
| Docker Compose | listen() in a container | Reproducible deploys, several services together |
| Cloudflare Workers | app.handle | Low latency everywhere, no servers |
| Vercel | app.handle in a function | Preview deploys next to a front end |
Before you deploy
Validate configuration at startup
Load the environment through defineConfig. A missing or malformed
variable then stops the process before it listens, and your process manager reports the
failure instead of serving errors.
const app = await Suku.create({ features: [Env.load(), AppFeature], adapter: nodeAdapter() });
const env = app.get(Env);Build for Node
Node cannot run TypeScript decorators, so bundle before you start.
bun build src/main.ts --target node --outdir dist
node dist/main.js- Bun reads
experimentalDecoratorsandemitDecoratorMetadatafrom yourtsconfig.json, so constructor injection keeps working in the bundle. - The bundle contains your dependencies.
dist/main.jsruns on its own, withoutnode_modules. - Packages with native code cannot be bundled. Keep them out with
--external, and shipnode_modulesfor those:bun build src/main.ts --target node --outdir dist --external pg-native. - The output is an ES module. Set
"type": "module"inpackage.json.
Bun can also run src/main.ts directly in production, with no build step.
Bind the right host
app.listen(3000) listens on 0.0.0.0, which is what a container needs. Behind a reverse
proxy on the same machine, bind loopback so nothing reaches the app except through the proxy.
await app.listen({ port: env.PORT, hostname: '127.0.0.1' });Shut down cleanly
On SIGTERM, app.close() stops accepting connections, lets requests in flight finish, then
disposes singletons such as a database pool. Add a deadline so one stuck request cannot block
a deploy.
const server = await app.listen({ port: env.PORT, hostname: '127.0.0.1' });
console.log(`listening on ${server.url}`);
let closing = false;
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
if (closing) return;
closing = true;
const deadline = setTimeout(() => void app.close({ force: true }), 10_000);
void app.close().finally(() => {
clearTimeout(deadline);
process.exit(0);
});
});
}app.close({ force: true }) also drops connections that are still open. Keep the deadline
shorter than the time your process manager waits before it kills the process: 30 seconds
below for systemd, 10 seconds by default for docker stop.
Health checks
Expose a cheap route for your process manager or load balancer.
const health = route.get('/health', { handle: () => ({ status: 'ok' }) });If the app uses a database, add a separate readiness route that checks it. See health checks.
Behind a proxy
Almost every deployment has a proxy or load balancer in front. Three things change, and none of them is automatic.
-
The client address is the proxy's.
ctx.remoteAddressis the socket's peer. Tell the rate limiter how many proxies you run, or every user shares one counter.rateLimit({ limit: 300, window: '1m', trustProxy: 1 });Set this only when a proxy really is in front.
X-Forwarded-Foris a header anyone can send. See rate limiting. -
The
Hostheader must be preserved. CSRF origin mode compares the request'sOriginwith itsHost. Nginx and Apache replaceHostunless you tell them not to. The configurations below do. -
ctx.urlsayshttp. TLS ends at the proxy, so the app sees plain HTTP. Build public links from a configured base URL, not fromctx.url.
Several processes
PM2 cluster mode, more than one container, and every serverless platform run several copies of your app. Each copy has its own memory.
- The default rate-limit store is per process, so a limit of 100 becomes 100 per copy. Use a shared store. See more than one instance.
- Each copy opens its own database pool. See pool size.
- Anything cached in a singleton is cached once per copy.
Deploy order
- Install dependencies and build.
- Run database migrations.
- Start or reload the app.
- Check
/healthbefore sending traffic.
VPS with PM2
Works for Node or Bun on a Linux VPS. Put Caddy, Nginx or Apache in front for TLS. See Reverse proxy.
1. Build and start scripts
{
"type": "module",
"scripts": {
"build": "bun build src/main.ts --target node --outdir dist",
"start": "node dist/main.js"
}
}2. Ecosystem file
module.exports = {
apps: [
{
name: 'api',
script: 'dist/main.js',
instances: 'max', // one worker per CPU core
exec_mode: 'cluster', // Node only
kill_timeout: 15000, // longer than the shutdown deadline in main.ts
max_memory_restart: '512M',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
},
],
};Cluster mode shares one port between the workers. Read several processes before you turn it on. To run on Bun instead, use one process in fork mode:
{
name: 'api',
script: 'src/main.ts',
interpreter: 'bun',
instances: 1,
exec_mode: 'fork',
env: { PORT: 3000 },
}3. Deploy
git pull
bun install --frozen-lockfile
bun run build
# run database migrations here
pm2 startOrReload ecosystem.config.cjs
pm2 saveRun pm2 startup once so the app comes back after a reboot. pm2 reload restarts cluster
workers one at a time, so the API stays up. Logs are in pm2 logs api.
VPS with systemd
No process manager to install: a unit file owns the process, restarts it, and collects its logs.
[Unit]
Description=Suku API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/api
Environment=NODE_ENV=production
EnvironmentFile=/var/www/api/.env
ExecStart=/usr/bin/node dist/main.js
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.targetFor Bun, set ExecStart=/usr/local/bin/bun run src/main.ts. Make .env readable only by the
service user: chmod 600 /var/www/api/.env.
sudo systemctl daemon-reload
sudo systemctl enable --now suku-api
sudo systemctl status suku-api
journalctl -u suku-api -fAfter a deploy:
bun install --frozen-lockfile && bun run build
sudo systemctl restart suku-apiA restart drops the port for a moment. Requests in flight finish first, because of the shutdown handler, but new ones are refused until the process is back. For no gap at all, run two units on different ports behind the proxy and restart them one at a time.
Reverse proxy
Bind the app to loopback (127.0.0.1:3000) and end TLS at the proxy. Each configuration below
keeps the original Host and adds X-Forwarded-For. See behind a proxy
for what the app needs on its side.
Caddy obtains and renews certificates automatically.
api.example.com {
reverse_proxy 127.0.0.1:3000
}Headers are set for you. Reload with caddy reload --config /etc/caddy/Caddyfile.
Docker Compose
Dockerfile
The bundle carries its dependencies, so the final image needs only Node and one file.
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build src/main.ts --target node --outdir dist
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=build /app/package.json ./
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]If you marked packages --external, also copy node_modules into the runner.
node_modules
dist
.git
.envIn a container the app must listen on 0.0.0.0, not loopback, or the port mapping reaches
nothing. Use the exec form of CMD, as above, so SIGTERM reaches Node and the shutdown
handler runs.
To run on Bun with no build step:
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY . .
ENV NODE_ENV=production
ENV PORT=3000
USER bun
EXPOSE 3000
CMD ["bun", "run", "src/main.ts"]Compose
services:
api:
build: .
env_file: .env
environment:
PORT: 3000
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
stop_grace_period: 15s
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/health']
interval: 30s
timeout: 3s
retries: 3
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app']
interval: 5s
timeout: 3s
retries: 10
caddy:
image: caddy:2
ports:
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on: [api]
restart: unless-stopped
volumes:
db_data:
caddy_data:api.example.com {
reverse_proxy api:3000
}Only Caddy publishes ports. The API and the database are reachable on the compose network
alone. depends_on with service_healthy starts the API after PostgreSQL accepts
connections, which matters because Suku fails fast when
the database is not there.
docker compose up -d --build
docker compose logs -f apiCloudflare Workers
Workers already speak Fetch, so there is no adapter: pass each request to app.handle.
@sukujs/core, @sukujs/crypto and @sukujs/security use only Web APIs and run without the
nodejs_compat flag.
Entry
import { route, Suku } from '@sukujs/core';
const hello = route.get('/hello/:name', {
handle: ({ params }) => ({ message: `Hello, ${params.name}` }),
});
// No adapter: Workers call handle(), they do not listen().
const app = await Suku.create({ routes: [hello] });
export default {
fetch: (request: Request) => app.handle(request),
};Write fetch as a function that passes only the request. Workers call it with
(request, env, ctx), and app.handle takes its own second argument.
Bindings and secrets
Variables, secrets, KV and D1 arrive in env with the first request, not at import time. So
create the app on the first request and keep it. defineConfig
validates the bindings like any other source.
import { defineConfig, Suku, type SukuApplication } from '@sukujs/core';
import { z } from 'zod';
import { AppFeature } from './app';
export const Env = defineConfig('env', z.object({ JWT_SECRET: z.string().min(32) }));
let app: Promise<SukuApplication> | undefined;
const createApp = async (env: Record<string, unknown>) =>
Suku.create({ features: [await Env.load({ ...env }), AppFeature] });
export default {
async fetch(request: Request, env: Record<string, unknown>): Promise<Response> {
app ??= createApp(env);
const { handle } = await app;
const remoteAddress = request.headers.get('cf-connecting-ip') ?? undefined;
return handle(request, remoteAddress ? { remoteAddress } : undefined);
},
};Routes then take Env as a service, as they do everywhere else. Passing cf-connecting-ip
as remoteAddress gives the rate limiter the real client address, with no trustProxy.
Constructor injection
Wrangler bundles with esbuild, which does not emit decorator metadata. A @Routes class or a
@Service with constructor parameters then fails at startup with "no type metadata was
emitted". Pick one of two fixes.
List the dependencies. No build step, and it works with any bundler.
@Routes('/users')
export class UsersRoutes {
static inject = [UsersService];
constructor(private readonly users: UsersService) {}
}Or bundle with Bun first, which does emit the metadata, and point Wrangler at the result.
bun build src/worker.ts --target browser --format esm --outfile dist/worker.jsClassless route.* and service() need neither.
Wrangler
{
"name": "suku-api",
"main": "src/worker.ts", // or dist/worker.js when you bundle with Bun
"compatibility_date": "2026-11-01"
}npx wrangler dev # run locally in the Workers runtime
npx wrangler secret put JWT_SECRET
npx wrangler deployWhat is different on Workers
- No Node-only APIs.
fs,node:httpand native addons are not available. - Many short-lived instances. The default rate-limit store is per instance, so it limits little here. Use a shared store or Cloudflare's own rate limiting rules.
- CPU time is limited.
hashPasswordis slow on purpose and can exceed the CPU limit of the free plan. Check your plan's limit before you hash passwords in a Worker. - Nothing calls
app.close().disposedoes not run, so do not rely on it. - Databases. Use an HTTP driver, D1, or Hyperdrive. See serverless and edge.
Vercel
The same idea as Workers: no listen(), one function that passes every request to Suku.
Use the Node.js runtime, which Vercel recommends over the Edge runtime.
The Workers and Node sections on this page were run against the published packages. This
one follows Vercel's documentation for the fetch export and has not been run by our tests.
App
import { route, Suku } from '@sukujs/core';
const health = route.get('/health', { handle: () => ({ status: 'ok' }) });
const hello = route.get('/hello/:name', {
handle: ({ params }) => ({ message: `Hello, ${params.name}` }),
});
export const app = await Suku.create({ routes: [health, hello] });Function
Keep one function for the whole API. A file per endpoint would build a separate function for each and start each one cold.
import { app } from '../src/app';
export default {
fetch: (request: Request) => app.handle(request),
};Routing
Send every path to that function.
{
"rewrites": [{ "source": "/(.*)", "destination": "/api" }]
}vercel dev # run locally
vercel deploy --prodWhat is different on Vercel
- Set
"type": "module"inpackage.json, so the function is an ES module and top-levelawaitworks. - No
pathsaliases. Vercel compiles TypeScript with yourtsconfig.jsonbut does not support path mappings. Use relative imports. - Constructor injection. If startup fails with "no type metadata was emitted", list
dependencies with
static inject, as in the Workers section. - Environment variables come from the project settings and are in
process.env, soEnv.load()works unchanged. - Instances come and go. The notes on several processes and serverless databases apply.
Long-lived connections, local disk and heavy startup work fit poorly here. For an always-on API, a VPS or a container is simpler.
Checklist
- Configuration is validated at startup, and secrets come from the environment.
- The app binds
127.0.0.1behind a local proxy, and0.0.0.0in a container. SIGTERMcallsapp.close(), with a deadline shorter than the process manager's./healthexists and the platform checks it.- TLS ends at a proxy that preserves
Host, andtrustProxymatches the number of proxies. - Rate limits use a shared store when more than one copy runs.
- Migrations run before the new version starts.
- Security headers and CORS are configured for the public origin.
See Node and Bun for the adapters, listen and close, and writing your
own adapter.