Skip to Content

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.handle and never listen.
HostHow Suku runsUse when
VPS + PM2listen() under a process managerA simple always-on API, several Node workers
VPS + systemdlisten() under a unit fileNo extra runtime, tight control of the OS
Docker Composelisten() in a containerReproducible deploys, several services together
Cloudflare Workersapp.handleLow latency everywhere, no servers
Vercelapp.handle in a functionPreview 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.

src/main.ts
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 experimentalDecorators and emitDecoratorMetadata from your tsconfig.json, so constructor injection keeps working in the bundle.
  • The bundle contains your dependencies. dist/main.js runs on its own, without node_modules.
  • Packages with native code cannot be bundled. Keep them out with --external, and ship node_modules for those: bun build src/main.ts --target node --outdir dist --external pg-native.
  • The output is an ES module. Set "type": "module" in package.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.

src/main.ts
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.remoteAddress is 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-For is a header anyone can send. See rate limiting.

  • The Host header must be preserved. CSRF origin mode compares the request's Origin with its Host. Nginx and Apache replace Host unless you tell them not to. The configurations below do.

  • ctx.url says http. TLS ends at the proxy, so the app sees plain HTTP. Build public links from a configured base URL, not from ctx.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

  1. Install dependencies and build.
  2. Run database migrations.
  3. Start or reload the app.
  4. Check /health before 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

package.json
{
  "type": "module",
  "scripts": {
    "build": "bun build src/main.ts --target node --outdir dist",
    "start": "node dist/main.js"
  }
}

2. Ecosystem file

ecosystem.config.cjs
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 save

Run 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.

/etc/systemd/system/suku-api.service
[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.target

For 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 -f

After a deploy:

bun install --frozen-lockfile && bun run build
sudo systemctl restart suku-api

A 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.

Caddyfile
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.

Dockerfile
# 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.

.dockerignore
node_modules
dist
.git
.env

In 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:

Dockerfile
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

docker-compose.yml
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:
Caddyfile
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 api

Cloudflare 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

src/worker.ts
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.

src/worker.ts
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.js

Classless route.* and service() need neither.

Wrangler

wrangler.jsonc
{
  "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 deploy

What is different on Workers

  • No Node-only APIs. fs, node:http and 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. hashPassword is 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(). dispose does 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

src/app.ts
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.

api/index.ts
import { app } from '../src/app';

export default {
  fetch: (request: Request) => app.handle(request),
};

Routing

Send every path to that function.

vercel.json
{
  "rewrites": [{ "source": "/(.*)", "destination": "/api" }]
}
vercel dev            # run locally
vercel deploy --prod

What is different on Vercel

  • Set "type": "module" in package.json, so the function is an ES module and top-level await works.
  • No paths aliases. Vercel compiles TypeScript with your tsconfig.json but 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, so Env.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.1 behind a local proxy, and 0.0.0.0 in a container.
  • SIGTERM calls app.close(), with a deadline shorter than the process manager's.
  • /health exists and the platform checks it.
  • TLS ends at a proxy that preserves Host, and trustProxy matches 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.

On this page