Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
Production

Deployment

Serving, persistence, and hardening for production.

An ohne app deploys as the source you wrote. There is no build step: Node 26 runs the .ts files directly, so what you deploy is your repository plus its installed dependencies. Production runs the same commands as your machine: ohne serve api, and ohne serve dashboard if you use the dashboard. Each is a plain long-running process.

What a deploy needs

Install, then serve:

Terminal
npm install
npx ohne serve api

The install runs ohne prepare through the scaffolded prepare script, so the types are ready for npm run typecheck in CI. The server regenerates them at boot anyway, so it never runs with outdated types. SKIP_CODEGEN=1 skips that step when the install has already prepared them.

The API and the dashboard are separate processes on separate ports, set by api.port and dashboard.port. Run each under your process manager:

Terminal
npx ohne serve api
npx ohne serve dashboard

The dashboard tells the browser where the API is, and the API lets the dashboard's origin call it. Both addresses come from the config, so PORT moves only the process it is set on. That only works when the browser can reach those hosts directly. Behind a proxy, across domains, or when your platform assigns each process a PORT:

  • Set dashboard.apiURL, or the API_URL env var, to the public API base URL, api.basePath included.
  • Set dashboard.origin, or DASHBOARD_URL, to the dashboard's own public origin, so CORS lets it call the API.

Readiness

The port opens last. Layers load, boot files run, codegen writes, the database connects and its schema syncs, and only then does the server listen. So an accepted connection means the server is ready: point your platform's probe at the port, as a TCP check or a request to any route you serve.

A failed boot, like a refused sync or a bad config, never opens the port. The probe never passes, and the previous instance keeps serving.

Supervisors get more signals once the server listens:

  • The process prints API ready at `http://...` .
  • A process spawned with an IPC channel sends a 'ready' process message.

To announce the address yourself, to a service registry for example, register the server:ready hook from a boot file.

Schema changes

The schema sync runs inside every boot, guarded against data loss. Run this check in your pipeline before the new build takes over:

Terminal
npx ohne sync --dry-run

It rehearses the whole sync against the live database and rolls it back, so a bad schema change fails CI while the old build still serves. Syncing without serving explains how to apply the change after that.

Data on disk

SQLite is a file: .data/ohne.db by default, relative to the project root, and overridden by database.url or the DATABASE env var. It runs in WAL mode, so two sidecar files sit beside it, ohne.db-wal and ohne.db-shm. The three files are one database.

In production that means:

  • Keep the data directory on a persistent volume. A container's writable layer disappears with the container, and so does the database.
  • Never separate the three files. Move, copy, and restore all three together.
  • Back up with the app stopped, where copying the directory is enough, or use SQLite-aware tooling against the live file. A plain file copy while the app writes can copy the file and its journal at different moments and produce a corrupt copy.

Helper databases are files too, each with its own sidecar files. The same rules apply.

Behind a proxy

By default ohne trusts no proxy: api.trustProxy is empty, forwarding headers are ignored, and ohne trusts only the socket connection itself. Behind a load balancer, event.ip is then the balancer's address and event.url reads as plain http.

ohne.config.ts
import { defineConfig } from 'ohnejs';

export default defineConfig({
  layers: ['ohnejs/base'],
  api: {
    trustProxy: ['10.0.0.0/8'],
    allowedHosts: ['api.example.com', '*.example.com'],
  },
});
  • trustProxy lists the CIDR ranges your proxies connect from. ohne then trusts their X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host headers. The RFC 7239 Forwarded header is ignored, since proxies often pass it through unchanged from the client.
  • allowedHosts limits which hosts the server answers to: a request whose Host matches none of the patterns is refused with 400 before routing. When empty, the server answers to any host.

If the proxy limits request bodies, keep uploads.chunkSize under that limit, so every chunk of a large upload gets through.

Dashboard caching

The dashboard names each module by a fingerprint of its content, like /m/utils/index.ts?h=47DEQpj8, and lets the browser cache it for a year. The page is sent with no-cache, so a reload after a deploy loads the changed files and keeps the rest. A deploy needs no cache purge.

A proxy or CDN in front of the dashboard must:

  • keep the query string in its cache key, since the fingerprint lives there.
  • pass Cache-Control through unchanged. Some CDNs rewrite it by default, so compare a module's headers through the CDN with curl -I against the origin's.

Otherwise the browser can keep old code under a new name.

CORS

The ohnejs/base layer mounts a global cors middleware: only the dashboard's origin may read API responses, cookies included.

  • When the dashboard is reached at any address other than dashboard.host on dashboard.port, like a public domain or a port changed with PORT, set DASHBOARD_URL or dashboard.origin to that origin. Otherwise the browser blocks its requests.
  • Set DASHBOARD_URL on the API process, not the dashboard's.
  • Every browser frontend that signs in needs its origin in the cors policy, with credentials. A write that rides the session cookie from any other page gets a 403, as cross-site requests explains.
  • Behind a proxy, set api.trustProxy so the API knows its public origin.

Secrets

Each secret is a long random value. Set the ones your app uses:

  • COOKIE_SECRET signs cookies. Without it, signing throws.
  • UPLOADS_SECRET signs image variant URLs and the links of private files. Without it, a private file opens only for a signed-in reader and variant URLs are unsigned.
Terminal
COOKIE_SECRET=zug-zug-work-work-jobs-done
UPLOADS_SECRET=frostmourne-hungers

When you store files with @ohnejs/s3, also set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.

A Postgres URL with a password is a secret too, so set it as DATABASE.

Set them in the process environment, or in a .env at the project root on a single-host deploy. The file only sets variables the environment does not have and never overrides it, so a value set by the platform always wins.

Graceful shutdown

SIGTERM and SIGINT both start the same ordered drain: stop accepting connections, wait for in-flight requests and their waitUntil work, then close the database. These api settings control it:

  • preStopDelay - keep serving this long after the signal before refusing connections. This gives the load balancer time to deregister the instance. Default: refuse at once.
  • shutdownTimeout - how long in-flight work may take to finish. When it expires, every request still in flight is cancelled, even one that already answered and only runs waitUntil work: its connection closes and useRequest().signal aborts. Shutdown then waits for it to clean up. Default: wait with no time limit.
  • deadline - a global limit for all shutdown steps together: the pre-stop delay, the drain, that cleanup, and the database close. Default: wait with no time limit.

Work that ignores its signal holds shutdown until deadline. A handler past its handlerTimeout is answered with 503 but keeps running, so shutdown waits for it like any other request.

The process exits 0 on a clean drain, and 1 when the deadline passes first. Keep the total below your platform's kill timeout, or the platform SIGKILLs the process during the drain. Kubernetes gives 30 seconds by default, and PM2's kill_timeout gives only 1.6:

ohne.config.ts
import { defineConfig } from 'ohnejs';

export default defineConfig({
  layers: ['ohnejs/base'],
  api: {
    preStopDelay: '5s',
    shutdownTimeout: '20s',
    deadline: '28s',
  },
});

Capacity

The api group also holds the settings that control load. The API server lists each one with its default. For production:

SettingWhen to set it
maxConnectionsTo limit how many sockets one instance accepts at the same time. No limit by default.
maxBodySize, handlerTimeoutWhen some routes need larger bodies or more time. Prefer per route.
maxHeaderSize, headersTimeout, requestTimeoutTo drop oversized or slow clients sooner than Node's defaults, or to give slow uploads more time.
keepAliveTimeoutBehind a load balancer that reuses connections, set it above the balancer's idle timeout.
waitUntilTimeoutTo limit background waitUntil work, which a graceful shutdown otherwise waits for.

Sign-in runs its password checks on Node's thread pool and uses at most half of it, answering 503 past that. For more sign-ins at once, raise the pool with the UV_THREADPOOL_SIZE env var (4 by default), set before the process starts.

Rate limits count in each process's memory by default, so every instance allows a client the full limit. Point them at a shared helper database to count once.

Logs

Everything the app prints goes through one printer:

  • SILENT=1, or printer.silent in config, drops every line.
  • DEBUG=1 adds debug output.
  • The env var wins over config when set.