A Suprnova app compiles to a single self-contained binary that owns the web server, the migration runner, the scheduler, and the queue worker. Deploying is "copy the binary, set four environment variables, run it." This chapter covers what those four variables are, what the binary's subcommands do in production, and how the built-in health endpoint integrates with a platform's liveness probe. Platform-specific walkthroughs follow in Railway, Digital Ocean, and Hetzner.
The single binary
Your app builds to one binary with a clap subcommand surface:
# this needs --force AND a typed confirmation on an
# interactive terminal; see cli-migrations.md
One binary means one Docker image, one CI artifact, one deployment to verify. The same image runs the web service, the scheduler, the queue worker, and the workflow worker - you start a different subcommand for each.
Four production environment variables
Suprnova fails closed on boot if the production environment is misconfigured. The minimum set to deploy:
| Variable | What it does | Failure mode |
|---|---|---|
APP_ENV |
Selects the environment (production, staging, etc.). |
Defaults to local if unset - your app runs in dev mode in prod. |
APP_KEY |
32-byte AES-256 base64 key for Crypt, sessions, cookies, and pagination cursors. |
Boot returns a typed error and exits non-zero when APP_ENV is not local/dev/test and APP_KEY is missing or malformed. |
APP_URL |
Canonical absolute URL of your app (https://app.example.com). |
Defaults to http://localhost:8765; signed URLs, redirects, mail links, and absolute Inertia URLs all use this. |
DATABASE_URL |
Connection URL for your relational database. | Boot refuses to start when APP_ENV is production or staging and DATABASE_URL is unset - the dev SQLite fallback is rejected explicitly. |
Generate APP_KEY once with the CLI:
For key rotation, see Encryption -
APP_KEY_PREVIOUS (or the Laravel-compatible APP_PREVIOUS_KEYS)
takes a comma-separated list of older keys for decrypt-only fallback.
Beyond the four required vars, common production knobs:
| Variable | Default | Notes |
|---|---|---|
SERVER_HOST |
127.0.0.1 |
Use 0.0.0.0 in containers. |
SERVER_PORT |
8765 |
Match your platform's expected port. |
APP_DEBUG |
env-derived | false in production/staging/custom envs. Set explicitly if you want loud errors in staging. |
SERVER_MAX_BODY_SIZE |
per-handler default | Process-wide request body cap. |
SERVER_MAX_CONNECTIONS |
10000 |
Cap on concurrent active TCP connections. See below. |
SERVER_HEALTH_READINESS_TOKEN |
unset (readiness is public) | Shared secret required to reach the readiness probe. See Health check. |
DB_MAX_CONNECTIONS |
10 |
Pool size. |
REDIS_URL |
unset | Required if you've configured the Redis cache/queue/session drivers. |
The full table lives in Environment Variables.
Recommended database: MariaDB
Suprnova supports SQLite, PostgreSQL, MySQL, and MariaDB as first-class relational backends. The recommendation is environment-specific:
- Development. SQLite. The scaffolder writes
DATABASE_URL=sqlite://./database.dbsosuprnova serveworks with zero database setup. - Production. MariaDB. It collapses what would otherwise be three separate services (relational + vector + KV cache) onto one engine, with system-versioned tables for audit if you need them.
# .env.production
DATABASE_URL=mysql://app_user:secret@db.internal:3306/app_production
Use the mysql:// or the mariadb:// scheme. Both select the MySQL family
driver, which handles MariaDB natively. The framework accepts mariadb://
for the primary connection, named connections, read replicas, the migrator
and MariaDbVectorDriver::from_url. DB::driver_title() returns MariaDB
for a mariadb:// URL and MySQL for a mysql:// URL, because the URL is
the only thing that tells them apart. Suprnova's MariaDbVectorDriver
(VECTOR(N) + HNSW) hooks in directly for vector workloads.
DATABASE_URL=mariadb://app_user:secret@db.internal:3306/app_production
The other relational backends are first-class too:
# PostgreSQL
DATABASE_URL=postgres://app_user:secret@db.internal:5432/app_production
# MySQL
DATABASE_URL=mysql://app_user:secret@db.internal:3306/app_production
# SQLite (for tiny single-instance deploys)
DATABASE_URL=sqlite:///var/lib/myapp/data.db
Why Suprnova diverges
Laravel's defaults nudge new projects toward PostgreSQL because PHP +
PostgreSQL is the well-trodden path. Suprnova picks the database that
gives the cleanest single-engine production posture for a Rust app.
MariaDB's VECTOR(N) (11.7+), Dynamic Columns, and system-versioned
tables mean a small-to-mid product can ship search, KV, and audit
without bolting on Redis, OpenSearch, or pgvector. PostgreSQL stays
fully supported - the framework's test matrix runs against all three
relational backends - but our deployment docs lead with the engine
that minimises moving parts. See
Vector Storage and Database for the
backend-specific surfaces.
Building a production image
The scaffolder ships a generator for a multi-stage Dockerfile:
This writes a Dockerfile with three stages:
- Frontend build -
node:20-alpine, runsnpm ci && npm run buildagainst yourfrontend/Inertia app (Svelte 5, React 19, or Vue 3.5 per your scaffold choice). - Backend build -
rust:1.94.0-slim-bookworm, compiles your crate in release mode with dependency caching. - Runtime -
debian:bookworm-slim, copies the compiled binary, the Vite output, and your schema dumps (database/schema), installs the database client theDB_CLIENTbuild argument names (postgresql-clientunless you change it), runs as a non-rootappuser, exposes port 8765, and runsCMD ["./app"](the auto-migrating server). The client lets the server load a schema dump into a new, empty database; see Squashing migrations.
Current main also upgrades the database stack to SeaORM 2.0, SeaQuery 1.0,
and SQLx 0.9. Direct SeaORM code must import ExprTrait for expression methods
and use explicit *_raw methods for prebuilt Statement values. Existing
application data requires no migration.
Build and run locally to verify before pushing:
# With an env file
# Or with explicit vars (the four required ones)
Never commit .env.production (or any file containing APP_KEY or
DATABASE_URL) to your repo. Use your platform's secrets store and
read the values at deploy time.
Production build shape
The binary the Dockerfile builds never carries the framework's testing
feature - the encryption-key installers, storage fakes, and RenderCache
test hooks that a test suite needs are compiled out. Your scaffolded
Cargo.toml (and the dogfood app in the Suprnova repository itself) takes
this shape by construction with two dependency entries:
[dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v4.0.3", default-features = false, features = [
"filesystem", "database-sqlite", "database-postgres", "database-mysql",
"vector-mariadb", "web-push", "localization", "magnetar-oauth", "media",
"queue-sqs",
] }
[dev-dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v4.0.3", features = ["testing"] }
The production entry turns default features off and lists the ten that
stay on (every default except testing). The dev-dependency turns
testing back on - Cargo's feature resolver only pulls in a
dev-dependency's features for cargo test and other --tests builds, so
cargo build --bin app (or --bin console) never sees it. testing is a
dev-dependency feature only: nothing in your own src/ or cmd/ should
turn it on for a binary target.
The framework repository proves this holds with a small crate whose only
job is to fail to compile: it references every framework item gated
behind testing by path, and the release gate checks that referencing
them without the feature is a compile error while referencing them with
it succeeds.
Migrations on boot
The default ./app (and explicit ./app serve) command applies any
pending migrations before binding the socket. The two practical
implications:
- Safe with multiple instances. SeaORM's migration runner takes a database-level advisory lock; the slowest pod waits, the others proceed once it's done. You do not need a separate "migrate-then-deploy" step for routine release rolls.
- Failed migration = failed deploy. If a migration errors, the process exits non-zero before the server binds. The platform's health probe (see below) reports the pod unhealthy, and the rollout halts. Fix forward by shipping a corrective migration in the next release.
For CI pipelines that want to gate the deploy on a successful migration before any pod accepts traffic, run migrations in a one-shot:
# … then roll the actual deploy
--no-migrate skips the auto-migrate phase but still boots the server
normally.
Workers as separate services
The scheduler, queue, and workflow systems each have their own daemon subcommand. In production, run them as separate processes against the same image, sharing the same environment:
Two rules to internalise:
- Either run exactly one
schedule:workprocess, or mark your tasks.on_one_server(). Scheduler replicas do not coordinate by default: each evaluates the schedule independently, so three replicas run every due task three times.replicas: 1is the simple answer;.on_one_server()elects one replica per tick against a shared cache and is what you want if the scheduler has to be highly available. See Scheduling. - Queue and workflow workers scale horizontally. Both pull work
from a shared store and use visibility timeouts or row-level locks
to coordinate; adding pods adds throughput.
./app queue:work --max-jobs Nmakes the worker exit after N jobs so a supervisor can rotate the process - useful for release-on-restart deploys.
See Queues, Scheduling, and Workflows for the per-subsystem detail.
Stopping cleanly
Every long-running Suprnova process - the server and all three daemons -
drains on SIGTERM as well as SIGINT. SIGTERM is what docker stop,
Coolify, systemd and Kubernetes send; SIGINT is what Ctrl-C sends. Both
take the same path: stop accepting new work, finish what is in flight
within a bounded grace, exit 0.
The grace windows are per-subsystem and bounded on purpose - one slow client or one long task must not be able to keep a process alive indefinitely:
| Process | Waits for | Grace |
|---|---|---|
serve |
in-flight HTTP connections | 5s |
queue:work |
the in-flight job to settle | until the job returns |
schedule:work |
.run_in_background() tasks |
30s |
workflow:work |
in-flight workflow steps | until they return |
Each of them then waits up to ten more seconds for the queued event
listeners still running, so a listener a job or a request started is
not cut off; so does schedule:run once its tasks have run. See
Events.
Size your platform's termination grace above these. Docker defaults to 10 seconds, Kubernetes to 30. If the platform's window is shorter than the work takes, it sends SIGKILL and you are back to losing in-flight jobs:
# docker compose
services:
worker:
command:
stop_grace_period: 60s
# kubernetes
spec:
terminationGracePeriodSeconds: 60
A job killed mid-flight is not lost, but it does cost an attempt. Its reservation lapses and another worker reclaims it, charging one attempt so a job that reliably kills its worker can still be dead-lettered rather than cycling forever. See Queues.
PID 1 is a real constraint. A container entrypoint runs as PID 1, and
the kernel does not apply default signal dispositions to PID 1 - a
process with no SIGTERM handler does not die on SIGTERM, it ignores it
until the platform gives up and sends SIGKILL. Suprnova installs the
handler, so CMD ["app", "queue:work"] is fine as written and no tini
shim is required.
Health check
Suprnova exposes three built-in health paths. The _suprnova/ prefix is
reserved so your own routes can never collide with them.
| Path | Touches | Use for |
|---|---|---|
/_suprnova/health/live |
nothing | Liveness. Answers 200 for as long as the process can serve a request. |
/_suprnova/health/ready |
the database | Readiness. 503 when a dependency is unreachable. |
/_suprnova/health |
nothing, or the database with ?db=true |
The original endpoint. Behaves as either of the above. |
# 200 {"status":"ok","timestamp":"2026-05-30T12:34:56+00:00"}
# Healthy: 200 {"status":"ok","timestamp":"…","database":"connected"}
# Degraded: 503 {"status":"degraded","timestamp":"…","database":"error"}
/_suprnova/health and /_suprnova/health?db=true keep working exactly
as before, and nothing you have already deployed needs changing - the
Hetzner guide still names them for one-off
checks, and so may your own specs. The named paths are clearer, so
prefer them in new configuration; the Railway,
DigitalOcean and Docker
guides use them.
Use the right probe for the right question
Point liveness at /live and readiness at /ready. The distinction
matters more than it looks: a failed liveness probe restarts the pod,
while a failed readiness probe only pulls it out of the load
balancer. Wire a database check into liveness and a database blip
restarts every replica you have - at the exact moment the database can
least afford a thundering herd of reconnects.
livenessProbe:
httpGet:
path: /_suprnova/health/live
port: 8765
readinessProbe:
httpGet:
path: /_suprnova/health/ready
port: 8765
The endpoint short-circuits before the middleware chain so it stays responsive even if a middleware deadlocks or the request id middleware is rejecting traffic.
Degraded responses do not carry driver detail
The 503 body reports "database":"error" and nothing more. The driver's
own message - which names hosts, ports, database and schema names and
server versions, and for some configuration errors the connection URL -
goes to the log at error! level, where an operator can read it and a
stranger cannot. In debug builds it is also included in the body as
database_error, so local debugging is unaffected.
Closing readiness off
Readiness runs a database round trip for whoever asks. If the endpoint is internet-reachable, set a shared secret:
SERVER_HEALTH_READINESS_TOKEN=<a
Probes must then send it as a header:
readinessProbe:
httpGet:
path: /_suprnova/health/ready
port: 8765
httpHeaders:
- name: X-Suprnova-Health-Token
value: <the same value>
Without the header, readiness answers 404 - the same response as any path that does not exist, so the endpoint is invisible rather than merely closed. Liveness stays public either way, so you do not have to put the secret in every manifest to keep your restart-on-hang signal.
Unset is the default, and readiness is public. That is deliberate: the
configurations this manual and the scaffolder generate all call
?db=true without a header, and defaulting to closed would break them.
Maintenance mode
To roll a destructive migration or quiesce traffic for an incident:
down writes a maintenance marker the middleware reads on every
request. Requests get a 503 (configurable via --status) with the
provided message, except for paths in --except and any request that
includes the secret. up removes the marker.
The secret is a bearer credential: anyone who visits /<secret> is issued
a bypass cookie good for 12 hours. The deadline is sealed inside the
encrypted cookie and re-checked on every request, so a captured cookie
stops working on time even if the browser ignores its max-age. A cookie
whose deadline is further out than one TTL is refused, with a short
allowance for clock differences between hosts. Both the URL match and the
cookie's secret comparison run in constant time, so response timing does
not tell a prober how long a prefix they guessed correctly. Prefer
--with-secret, which mints one for you (16 random bytes, 32 hex
characters) and prints the bypass URL, over picking a memorable string for
--secret - and treat it like any other credential in your incident
notes.
down prints what it did, in Laravel's words:
Application is now in maintenance mode.
You may bypass maintenance mode via [https://example.com/abc123].
A down while the application is already down updates the options and
prints Maintenance mode options updated. instead. The bypass address is
APP_URL followed by the secret. When down cannot record maintenance
mode, it prints Failed to enter maintenance mode: <error>. and exits
with a failing status.
--retry takes a number of seconds or a date. A date is sent as the
Retry-After header in the RFC 7231 form, so a client reads when to come
back:
--render <view> renders the Tera template at
resources/views/<view> once, when you run down, and serves the result
as the maintenance page. The template reads retry_after: the seconds or
the date of --retry, and empty without one. A template that cannot be
read or rendered fails down before maintenance mode starts, so a typo
never takes the site down with a broken page. --render and --message
exclude each other.
<!-- resources/views/errors/503.html -->
Back soon
Try again after {{ retry_after }}.
After it records maintenance mode, down dispatches the
MaintenanceModeEnabled event, which carries no data. Listen for it to
tell a status page or the people on call:
use ;
use Arc;
;
// In bootstrap::register():
pub async
A listener that fails is logged, and down still reports the state it
recorded: maintenance mode is on either way. Scheduled tasks skip their
runs while the application is down, unless they ask
even_in_maintenance_mode(); see Scheduling.
Why Suprnova diverges
Laravel drops a --retry value that is neither a number nor a date it
can parse, and sends no Retry-After. Suprnova refuses it, so the header
you asked for is the one clients get. Laravel renders --render with a
Blade view; Suprnova renders a Tera template with retry_after in its
context.
Serving under a path prefix
A reverse proxy can serve the application under a path, such as
https://example.org/billing, and strip that path before it forwards the
request. The application then receives /invoices/7 for the browser's
/billing/invoices/7. Routes still match on the path the application
receives, so you don't change a single route. Every URL the framework hands
the browser has to carry the stripped path again, and the proxy tells the
framework what it is with the X-Forwarded-Prefix header.
This nginx location serves an application listening on port 8000 under
/billing:
location /billing/ {
proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /billing;
}
The trailing slash on proxy_pass is what strips /billing. Caddy does the
same with handle_path:
example.org {
handle_path /billing/* {
reverse_proxy 127.0.0.1:8000 {
header_up X-Forwarded-Prefix /billing
}
}
}
Then trust the proxy, and give APP_URL the prefix too:
APP_TRUSTED_PROXIES=127.0.0.1
APP_URL=https://example.org/billing
The trust rule
The framework reads X-Forwarded-Prefix under the rule every other
forwarded header follows: only when the TCP peer is listed in
APP_TRUSTED_PROXIES. From any other peer the header is ignored. The proxy
sets or clears X-Forwarded-Prefix on every request it forwards, as it does
for X-Forwarded-For. A proxy that passes a client's header on as it came
lets the client choose the root of every URL the application builds.
A trusted value counts only when it is a clean path: ASCII letters, digits,
-, ., _, ~ and /, starting with /, not ending with /, and with
no empty, . or .. segment. A header sent on two lines is ignored whole. A
trusted / means the application is at the host root, and an empty value is
ignored. An ignored value leaves the root to APP_URL.
The public root
The public root is the trusted prefix when the request carries one,
otherwise the path in APP_URL, which is also the root outside a request:
in console commands, queued jobs and mail. A trusted prefix replaces the
APP_URL path, so the two never add up to /billing/billing. At the host
root the root is empty.
The framework puts the root in front of every URL it builds for the
browser: route() and the signed URLs, url::to, url::current,
Request::url, every redirect, the Inertia page URL and its Vite tags, Live's
endpoints and assets, the localization catalog, pagination links and a
Storage::url with a root-relative base. A path that already carries the
root, such as the output of route(), keeps it once.
A template writes the root with url::root(), which returns /billing
under the prefix and the empty string at the host root:
An Inertia frontend gets the root as the root prop, which RootShare
shares with every page. A scaffolded application registers it, and its pages
post to `${root}/login`, so the same frontend build runs at / and
under /billing.
Mail and job links are built outside a request, where no header arrives. A
deployment whose mail and job links must work behind a header-only prefix
sets the prefix in APP_URL as well, as the example above does.
Cookies
When the application sets no cookie path, the session, XSRF, remember-me and
maintenance bypass cookies take the root of each request as their Path, so
two applications under /a and /b on one host keep separate sessions. A
__Host- cookie keeps Path=/, as the browser requires. Two applications
that share a host and both use __Host- cookies need distinct cookie names,
set with SESSION_COOKIE.
When a browser sends two cookies of one name, the framework reads the first.
The browser lists the cookie with the longer path first, so after you move an
application under a prefix, a session cookie left over at Path=/ cannot
shadow the one scoped to the root.
Routes that begin with the prefix
The framework tells a path that carries the root from an application path by
its first segment. An application whose own routes begin with its
deployment prefix, such as a /billing route group behind a proxy that
serves /billing, can't be told apart, so don't give an application routes
that begin with the prefix. Let the proxy strip it instead.
Why Suprnova diverges
- A trailing slash is ignored, not trimmed. Symfony trims a trailing
slash from
X-Forwarded-Prefix. Suprnova ignores a value that ends with/, so each root has one spelling and two spellings never mean two cache keys or two cookie paths. Send/billing, not/billing/. - The relative
route()carries the root. Laravel'sroute($name, $parameters, false)strips the base URL. Suprnova'sroute()is relative and keeps the root, so a redirect or a link built from it works behind the prefix. - The
APP_URLpath is the root when no prefix header arrives. Laravel reads the root from the request alone. Suprnova falls back to the path inAPP_URL, so console commands, jobs and mail build the same URLs as a request does.
Scaling
Web
Horizontal scaling is the default story: every pod runs ./app,
shares DATABASE_URL, and connects to the same Redis (if you've
configured Redis-backed cache/queue/session). Auto-migration is safe
because of the advisory lock above. Sticky sessions are not required -
session state lives in your session driver (database or Redis),
not in process memory.
Workers
- Scheduler. Exactly one instance, always.
- Queue. Scale horizontally. If you've split work across multiple named queues, run a worker per queue (or pass driver-specific queue filters - see Queues).
- Workflow. Scale horizontally; row-level claim/heartbeat coordinates the workers.
Connection cap (SERVER_MAX_CONNECTIONS)
The server caps concurrent TCP connections at 10000 by default. The
header and body deadlines bound how long each connection lasts; the cap
bounds how many there are, so a crowd of slow clients cannot use up the
process's file descriptors. In most deployments a reverse proxy (nginx,
Caddy, Traefik) or the platform's load balancer provides the first line
of defence, and the cap is the backstop inside the process. To change
it, set SERVER_MAX_CONNECTIONS:
# .env.production - cap concurrent connections at 1024
SERVER_MAX_CONNECTIONS=1024
When the cap is reached the accept loop blocks (back-pressure at the TCP level) until an existing connection closes; the pending handshake remains in the kernel's accept backlog. The permit is held for the full lifetime of each connection and released the moment the connection ends, so slots turn over promptly.
Rules of thumb:
- Unset (default = 10000). Suits most deployments. Raise it if the process holds more long-lived connections (WebSocket, SSE) than that at peak.
- Set to a concrete value to size the cap to your traffic. A typical starting point is 2 × your expected peak concurrent users, adjusted upward for long-lived connections (WebSocket, SSE).
- Pair with
LimitNOFILE(systemd) orulimit -nso the OS file-descriptor limit doesn't become the surprise cap. Each HTTP connection costs one file descriptor; add your database pool size and a few dozen for OS housekeeping. Many systems start at 1024, below the default cap, so raise the limit or lower the cap to fit. - This is a backstop, not a replacement for upstream rate limiting.
SERVER_MAX_CONNECTIONSstops runaway accumulation; your reverse proxy orrate_limitmiddleware should handle per-client or per-IP throttling.
A blank value is the same as unset. A zero or unparseable value logs a warning and falls back to 10000 rather than removing the cap, so a typo neither stops the server from starting nor leaves it without a cap.
No value of SERVER_MAX_CONNECTIONS removes the cap. A server with no cap
is a choice made in code. Make it only when a reverse proxy or the
platform already limits connections and the file-descriptor limit is set
to match:
use ;
register;
Heap profiling
To see where a deployment's memory goes, build it with the framework's
heap-profiling feature. The framework then installs dhat's
allocator for the whole process, and #[suprnova::main] starts its
profiler before anything else runs. A new application has the feature
already, passed through to the framework:
[features]
heap-profiling = ["suprnova/heap-profiling"]
Build it with the profiling profile, the release profile with debug
symbols, so the profile's stacks name your functions:
SUPRNOVA_HEAP_PROFILE=/tmp/heap.json
When the process ends normally - a command that finishes, or a server or worker you stop with SIGTERM or Ctrl-C - it writes the profile and prints the totals:
dhat: Total: 533,023,630 bytes in 1,144,769 blocks
dhat: At t-gmax: 949,716 bytes in 4,412 blocks
dhat: At t-end: 492,592 bytes in 2,417 blocks
The total is everything the process allocated, t-gmax the heap at
its peak, and t-end what was still allocated when it ended. Open the
file in DHAT's viewer (dh_view.html) to see which call stacks
allocated what. Without SUPRNOVA_HEAP_PROFILE the file is
dhat-heap.json in the working directory.
A profile is written only when the process ends normally: a process
that is killed, or that calls std::process::exit, writes none. The
profiler records a backtrace for every allocation, so the process runs
slower while it measures; build a profiling binary for the
investigation, not for production. An application with its own
#[global_allocator] cannot turn the feature on, since a process has
only one allocator.
The profiling profile on its own also suits a sampling profiler such
as samply or perf.
Per-platform walkthroughs
The recipe above ports to every modern PaaS or VPS. The next three chapters walk you through the specifics:
| Platform | Style | Walkthrough |
|---|---|---|
| Railway | PaaS with auto-deploy from git | Deploy to Railway |
| Digital Ocean | App Platform (PaaS) or Droplets (VPS) | Deploy to Digital Ocean |
| Hetzner | VPS with systemd + Caddy | Deploy to Hetzner |
Next
- Environment Variables - every env var the framework reads
- Encryption -
APP_KEY, key rotation, what's encrypted - Configuration - typed config sections built on top of env
- Database - driver selection, pool tuning, multi-connection split
- Queues - worker scaling and queue drivers
