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:
npm install
npx ohne serve apiThe 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:
npx ohne serve api
npx ohne serve dashboardThe 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 theAPI_URLenv var, to the public API base URL,api.basePathincluded. - Set
dashboard.origin, orDASHBOARD_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:
npx ohne sync --dry-runIt 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.
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
api: {
trustProxy: ['10.0.0.0/8'],
allowedHosts: ['api.example.com', '*.example.com'],
},
});trustProxylists the CIDR ranges your proxies connect from. ohne then trusts theirX-Forwarded-For,X-Forwarded-Proto, andX-Forwarded-Hostheaders. The RFC 7239Forwardedheader is ignored, since proxies often pass it through unchanged from the client.allowedHostslimits which hosts the server answers to: a request whoseHostmatches none of the patterns is refused with400before 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-Controlthrough unchanged. Some CDNs rewrite it by default, so compare a module's headers through the CDN withcurl -Iagainst 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.hostondashboard.port, like a public domain or a port changed withPORT, setDASHBOARD_URLordashboard.originto that origin. Otherwise the browser blocks its requests. - Set
DASHBOARD_URLon 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.trustProxyso the API knows its public origin.
Secrets
Each secret is a long random value. Set the ones your app uses:
COOKIE_SECRETsigns cookies. Without it, signing throws.UPLOADS_SECRETsigns 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.
COOKIE_SECRET=zug-zug-work-work-jobs-done
UPLOADS_SECRET=frostmourne-hungersWhen 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 runswaitUntilwork: its connection closes anduseRequest().signalaborts. 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:
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:
| Setting | When to set it |
|---|---|
maxConnections | To limit how many sockets one instance accepts at the same time. No limit by default. |
maxBodySize, handlerTimeout | When some routes need larger bodies or more time. Prefer per route. |
maxHeaderSize, headersTimeout, requestTimeout | To drop oversized or slow clients sooner than Node's defaults, or to give slow uploads more time. |
keepAliveTimeout | Behind a load balancer that reuses connections, set it above the balancer's idle timeout. |
waitUntilTimeout | To 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, orprinter.silentin config, drops every line.DEBUG=1adds debug output.- The env var wins over config when set.