Deployment
Build a release binary, configure it through the environment, run the server, queue workers and the scheduler under a process manager, and put it behind a reverse proxy.
On this page
A Navree app deploys as one binary. The same executable serves HTTP, runs queue workers, runs the scheduler and migrates the database; you pick the job with its first argument. This page covers building that binary, the settings production needs, the processes to run, and putting the app behind a reverse proxy with health checks.
The examples use Linux with nginx and systemd because they are common. Navree doesn't depend on either: any reverse proxy that forwards HTTP and any process manager that runs a command and sends it a stop signal will do.
Building a release binary
cargo build --releaseThe binary is target/release/<package>, for example target/release/planner (planner.exe on Windows). Build it on the platform you deploy to, or cross-compile for it. TLS uses rustls and SQLite is compiled in, so the binary doesn't need OpenSSL or a SQLite library at runtime.
A release build changes two defaults: logs are written as JSON lines instead of the pretty format, and templates are read once instead of reloaded when they change. See Configuration: environments.
If you want more speed from the binary at the cost of longer builds, tune the release profile in your Cargo.toml. The benchmark's build of Navree used these settings:
[profile.release]
lto = "thin"
codegen-units = 1What to copy to the server
The binary reads .env and config/app.toml from its working directory, and keeps runtime data in storage/ by default. A deployment directory looks like this:
- plannerthe release binary
- config/
- app.tomlcopied from the repository
- storage/writable by the app's user
Copy config/app.toml with the binary and run every command from this directory (systemd's WorkingDirectory=). Template files under resources/views/, if your app uses them, go here too. Templates generated by make:mail are compiled into the binary.
Production settings
Production settings come from environment variables. They override config/app.toml, keep secrets out of the repository, and work the same for every process.
# The application key: the same one for every process of the app.
APP_KEY=base64:G60q3lWREHaFECJUwNhN58ArvmrSl0sptSaOEWgeBLk=
# Listen on loopback only; nginx is the public entry point.
APP_HTTP__ADDR=127.0.0.1:8080
# nginx runs on this machine: trust its X-Forwarded-For.
APP_HTTP__TRUSTED_PROXIES=127.0.0.1,::1
# Run migrations as a deploy step, not at every boot.
APP_DATABASE__MIGRATE_ON_BOOT=false
# The public URL, for links in mail.
APP_APP__URL=https://planner.example.com
APP_MAIL__TRANSPORT=smtps://user:[email protected]:465
APP_MAIL__FROM="Planner <[email protected]>"
# Require a bearer token on GET /metrics.
APP_OBSERVE__METRICS_TOKEN=a-long-random-stringCheck these before the first deploy:
| Setting | Why |
|---|---|
APP_KEY | Without it, JWTs and signed URLs use a random key per process, so every restart signs everyone out, and encryption fails. Every process needs the same key. See The application key. |
database.migrate_on_boot | New apps set it to true in config/app.toml. In production, run migrate as a deploy step instead, so a failed migration stops the deploy, not the server. |
database.url | The default is SQLite in storage/. For PostgreSQL, build with the postgres feature and set a postgres:// URL. See Database: getting started. |
http.addr | 127.0.0.1:8080 by default. Use 0.0.0.0:8080 when the proxy runs on another host or in another container. |
http.trusted_proxies | Without it, behind a proxy every request seems to come from the proxy's address, so the per-IP rate limit (600 requests a minute by default) and the login throttle apply to all your users together. |
app.url | Absolute links in mail, such as email verification, use it. Navree doesn't read X-Forwarded-Proto or Host to build URLs. |
observe.metrics_token | GET /metrics is public by default. Set a token, or block the path at the proxy. |
Keep the environment file readable only by root and the app's user: it holds the application key and the passwords. Navree never logs them; Config's debug output masks secret-looking keys and URL passwords.
The commands an app ships
The binary runs one command and exits, except for the long-running ones. Every command except check, list and help boots the app first, connecting to the database and, with migrate_on_boot, migrating it, so it needs the same configuration as the server. planner list prints them all; a new app has these:
| Command | What it does |
|---|---|
serve [--addr HOST:PORT] | Runs the HTTP server until it gets a stop signal. Running the binary with no command does the same. |
queue:work [--queue a,b] [--once] [--stop-when-empty] [--max-jobs N] | Runs queued jobs until it gets a stop signal. |
queue:failed, queue:retry <id or all>, queue:flush | Lists, retries or deletes failed jobs. |
migrate | Runs every pending migration. |
migrate:status | Shows which migrations have run. |
migrate:rollback | Rolls back the last batch. |
migrate:fresh [--seed] | Drops every table and migrates again. Never run it in production. |
db:seed [name...] | Runs the seeders. |
down [--retry SECS] [--message TEXT], up | Turns maintenance mode on and off. |
cache:clear | Empties the cache store. |
routes [--json] | Lists the routes and their access rules. |
openapi:export [path] | Writes the OpenAPI document to a file. |
access:role, access:grant, access:explain | Creates roles, grants permissions and explains access decisions. |
key:generate | Prints a new application key. |
check [--graph] | Validates the module graph without starting anything. |
Modules add their own. With ScheduleModule in the app you get schedule:work, schedule:run and schedule:list, and with AuditModule you get audit:prune. A new app includes neither. See Console commands and the CLI.
Processes to run
A typical deployment runs up to three long-lived processes from the same binary and configuration:
| Process | Command | Needed when |
|---|---|---|
| Web server | planner serve | Always. |
| Queue worker | planner queue:work | You queue jobs, queued mail or queued listeners. New apps use the database queue driver. |
| Scheduler | planner schedule:work | The app includes ScheduleModule and declares scheduled tasks. |
Instead of schedule:work, you can run planner schedule:run from cron every minute. Each tick of each task runs once however many schedulers share the database, so running the scheduler on several servers is safe.
All of them stop cleanly on SIGTERM (or Ctrl-C):
servestops accepting connections and waits up tohttp.shutdown_grace_secs(30 seconds) for requests in flight.queue:workfinishes the jobs it is running (up toqueue.workers, 4 by default) and takes no new ones.- Then the app waits up to 30 seconds for background tasks, and up to 30 seconds more for shutdown hooks, before it exits.
Give your process manager a stop timeout longer than that total.
If the app broadcasts over WebSockets (RealtimeModule), only serve holds the sockets. Workers and the scheduler post their broadcasts to it over HTTP: set realtime.relay_url (auto means http.addr) and give every process the same APP_KEY. See Real-time broadcasting.
Running under systemd
One unit per process. This one runs the web server:
[Unit]
Description=Planner API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=planner
Group=planner
WorkingDirectory=/srv/planner
EnvironmentFile=/etc/planner/planner.env
ExecStart=/srv/planner/planner serve
Restart=on-failure
RestartSec=5
# Requests drain for up to 30 s, background tasks and shutdown hooks for up to 30 s each.
TimeoutStopSec=100
[Install]
WantedBy=multi-user.targetThe queue worker's unit is the same with another description and command:
[Unit]
Description=Planner queue worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=planner
Group=planner
WorkingDirectory=/srv/planner
EnvironmentFile=/etc/planner/planner.env
ExecStart=/srv/planner/planner queue:work
Restart=on-failure
RestartSec=5
TimeoutStopSec=100
[Install]
WantedBy=multi-user.targetEnable and start them:
sudo systemctl daemon-reload
sudo systemctl enable --now planner planner-queueLogs go to standard output, so journalctl -u planner shows them, as JSON lines in a release build. To also write rotating log files, set log.file; see Logging.
A deploy, step by step
Build and copy
Build the release binary and copy it and
config/app.tomlto the server, next to the running version.Migrate
Run the migrations from the deployment directory, with the production environment:
Shell cd /srv/planner sudo -u planner sh -c 'set -a; . /etc/planner/planner.env; set +a; ./planner migrate'For a migration the running version can't work with, put the app in maintenance mode first with
./planner down, and run./planner upafterwards.Restart
Shell sudo systemctl restart planner planner-queueRequests that arrive while the server restarts fail: Navree doesn't hand its listening socket over to a new process. Run two instances behind the proxy and restart them one at a time if that matters.
Behind a reverse proxy
Navree serves plain HTTP. Put a reverse proxy in front of it to terminate TLS, and tell Navree which proxies to trust so it sees each client's real address.
This nginx server block proxies everything to the app on loopback:
server {
listen 443 ssl;
server_name planner.example.com;
ssl_certificate /etc/ssl/certs/planner.example.com.pem;
ssl_certificate_key /etc/ssl/private/planner.example.com.key;
# Navree's own limit is http.body_limit_bytes (2 MiB by default).
client_max_body_size 2m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Prometheus metrics: for your scraper, not the internet.
location = /metrics {
deny all;
}
# WebSockets, if the app uses RealtimeModule (ws://host/app/{key}).
location /app/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}
server {
listen 80;
server_name planner.example.com;
return 301 https://$host$request_uri;
}With APP_HTTP__TRUSTED_PROXIES=127.0.0.1,::1, Navree takes the client's address from X-Forwarded-For on requests that come from nginx. It reads the header from the right and takes the first address that isn't a trusted proxy, so a client can't choose its address by sending its own X-Forwarded-For. Requests from any other peer are identified by the peer's own address. trusted_proxies accepts addresses and CIDR blocks such as 10.0.0.0/8, which suits a load balancer with changing addresses.
http.trust_proxy = true trusts the headers from every peer. Use it only when nothing can reach the app except through the proxy; trusted_proxies is safer.
Navree sends security headers by default, including Strict-Transport-Security, which tells browsers to use HTTPS only. Serve the app over HTTPS, as above. See CORS and security headers.
Health checks
ObserveModule, which new apps include, serves two probes outside the API prefix, without authentication:
| Endpoint | Answers | Use it for |
|---|---|---|
GET /health/live | Always 200 {"status":"up"} while the process serves HTTP. It runs no checks. | Liveness: restart the process when it fails. |
GET /health/ready | 200 when every health check is up, 503 when one is down. Each check has 2 seconds. | Readiness: take the instance out of the load balancer while it fails. |
curl -s http://127.0.0.1:8080/health/ready{"status":"up","checks":{"cache":{"status":"up","latency_ms":0},"database":{"status":"up","latency_ms":0}}}DatabaseModule, CacheModule and StorageModule each register a check, and your modules can add their own. Both probes and /metrics keep answering during maintenance mode (http.maintenance_except), so an orchestrator doesn't restart instances that are down on purpose. Don't send an Authorization header to the probes: a present but invalid token is a 401 on every route, public ones included. Health and metrics has the details.
Containers
The same rules apply in a container: the binary, config/ in the working directory, settings from the environment, and APP_HTTP__ADDR=0.0.0.0:8080 so the server listens outside the container. The benchmark runs Navree in a two-stage image, building with rust:1-bookworm and running on debian:bookworm-slim (137 MB); its bench/frameworks/navree/Dockerfile is a working example. Because the benchmark app depends on Navree by path, that Dockerfile copies the repository's crates/ into the build stage, and an app of yours needs the same until the crates are published.
The database in production
SQLite is the default and suits a single server: Navree opens it in WAL mode with a 5-second busy timeout, so the server, workers and scheduler on one machine can share the file. Keep storage/ on a persistent disk and back the file up.
For several servers, use PostgreSQL. Turn on the postgres feature of navree and set database.url. Be aware of the testing status in 0.1: the benchmark runs Navree on PostgreSQL (the pool, the unit of work, pagination, inserts and the auth user lookup), but the test suite runs on SQLite, and the PostgreSQL-only paths, such as the queue's FOR UPDATE SKIP LOCKED job reservation, haven't been run against a real server. See the release notes.