Deployment
On this page
The sample project's defaults are chosen so a fresh clone runs with nothing installed. Several of them are wrong for production, deliberately and obviously. This is the list.
The checklist
| Setting | Development default | Production |
|---|---|---|
APP_DEBUG | true in .env.example | false |
APP_ENV | local | production |
DATABASE_URL | sqlite::memory: | a real database |
QUEUE_DRIVER | sync | database (or your own) |
MAIL_DRIVER | log | a real transport |
APP_URL | http://localhost:8000 | your actual origin |
server.trust_proxy | false | true only behind a proxy you control |
LOG_FORMAT | auto → pretty | auto → JSON, or set it |
SERVER_REQUEST_TIMEOUT | 0 (off) | a ceiling your slowest route clears |
CACHE_DRIVER | memory | shared, if anything schedules on_one_server |
The framework's own defaults are already the safe ones — app.env is
production and app.debug is false when nothing sets them, so a missing
.env fails closed. It is the sample's .env.example that is tuned for
development.
APP_DEBUG=false
With debug on, 5xx error messages reach the client, and those routinely contain a connection string, a file path, or a query. Panic details are disclosed too.
This is the single most important line in the file.
The database
DATABASE_URL=postgres://user:pass@host/app
SQLite in memory is wiped on exit, which is what makes it a good default and a catastrophic production setting.
Run migrations as a deploy step rather than relying on boot:
cargo run --release -- migrate --pretend # check
cargo run --release -- migrate # apply
That run is one batch, and migrate:rollback takes it back off as a unit.
migrate prints any step in the batch that cannot be undone, which is the one
moment that fact is worth knowing:
Not reversible, so `migrate:rollback` will refuse this batch:
0005_drop_legacy_column
Prefer rolling forward in
production; a down written months ago has been tested by nothing.
The sample runs them in a provider's boot, which is right for development and
for a single instance. With more than one instance starting at once you want
one migration step, not a race — see Migrations.
The queue
QUEUE_DRIVER=database
sync runs jobs inline, so a failed job fails the request that dispatched
it. That is exactly the coupling the queue exists to remove: a slow mail
server becomes a slow checkout, and a broken one becomes a failed order.
Switching needs three things:
QUEUE_DRIVER=database- the queue's tables — merge them into your migrator
- a worker process
cargo run --release -- queue:work --queue=high,default --max-jobs=1000
--max-jobs makes the worker exit after N jobs so a supervisor restarts it.
That is a cheap defence against a slow leak in a long-running process, and it
costs a second of startup.
Run reclaim_expired() periodically so jobs held by a worker that died become
available again.
queue:work exits non-zero if any job failed, which your supervisor should
notice.
MAIL_DRIVER=smtp # or ses | postmark | mailgun | sendgrid | resend
The default is log, which sends nothing — switch before you deploy, and
enable the sender's cargo feature (mail-smtp, mail-ses, …), or the boot
fails naming it. Each sender's settings are listed under
Mail — configuration.
In staging, set MAIL_ALWAYS_TO. It redirects every
message to one address, and it is the difference between testing a flow against
a copy of production data and emailing all of those customers.
Behind a proxy
ServerOptions::default().trust_forwarded_for(true)
or server.trust_proxy = true in config, which serve
reads.
Turn this on only when something you control terminates every connection.
Otherwise any client can set X-Forwarded-For and forge its own IP — which
matters because that is what
throttling keys on, so forging it defeats
rate limiting entirely.
Body limits
SERVER_MAX_BODY=2097152
2 MiB by default. Request bodies are buffered, so this is the only thing standing between the server and a client that streams gigabytes at it. Raise it deliberately, per what your endpoints actually accept.
Building
cargo build --release
The binary is self-contained apart from resources/views/ and anything you
read from storage/. Ship those alongside it, or point the builder at where
they are:
Rainier::new("/srv/my-app")
Logging
LOG_FORMAT=auto # JSON in production and staging, pretty elsewhere
RUST_LOG=info
RUST_LOG=warn,my_app=debug,rainier_server=debug
Standard tracing filters, and the line shape is
configured rather than hardcoded. auto is what you
want: production gets one JSON object per line with the fields at the top
level, where an aggregator's default parser finds them; a developer gets
colour.
Set it explicitly to read production logs by eye for an afternoon —
LOG_FORMAT=pretty on one box is a debugging tool, and an explicit value is
never second-guessed.
The builder installs a subscriber unless you call without_tracing(); call it
and install your own if you need a sink this does not cover:
Rainier::new(".").without_tracing()
Nothing here writes log files or rotates them. tracing_subscriber writes to
stdout, and collecting stdout is the platform's job — Docker, systemd, a
sidecar.
Health checks
router.get("/health", liveness); // no I/O
router.get("/health/ready", health::endpoint); // checks dependencies
Point the orchestrator's liveness probe at the first and its readiness probe at the second. Pointing liveness at the dependency check restarts every replica when the database blips, which turns a degradation into an outage. See Observability.
Which build is running
router.get("/health/version", || async { Response::json(&build_info!()) });
Pass the commit into the build and the answer stops being "read the pipeline backwards":
ARG GIT_SHA
ENV GIT_SHA=$GIT_SHA
RUN cargo build --release --locked
docker build --build-arg GIT_SHA="$(git rev-parse HEAD)" .
GITHUB_SHA is already set inside a GitHub Actions build, so a CI image gets
it with nothing added. See build_info!().
Timeouts and compression
SERVER_REQUEST_TIMEOUT=30 # 0 is off, and is the default
SERVER_COMPRESSION=false # true when Rainier is what clients talk to
A handler that never returns holds its connection and its task for as long as
the process lives, and enough of those is a service that has stopped answering
everything with nothing in the log about the endpoint that hung. Thirty seconds
is a reasonable first answer for an API; a route that legitimately takes longer
should carry its own Timeout rather than this being
raised for everything.
Leave compression off if nginx or a CDN is already doing it — compressing twice is CPU spent to produce the same bytes.
Graceful shutdown
let (tx, rx) = tokio::sync::watch::channel(false);
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
tx.send(true).ok();
});
Server::from_arc(kernel).run_until(rx).await?;
For the worker, worker.stop() asks it to finish the current job and exit —
which is what makes a deploy not lose work in flight.
A reverse proxy
There is no public/ and no document root: serve is the server. Put
nginx, Caddy or a CDN in front of it for TLS, static assets, and connection
handling, and forward everything else.
location / {
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;
}
With that in place, and only then, set trust_proxy.
A container
The sample project ships a working Dockerfile; this is what is
in it and why.
FROM rust:1.100.0-bookworm AS builder
WORKDIR /build
# Dependencies in their own layer: manifests, a stub main, then the real source.
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src \
&& echo 'fn main() {}' > src/main.rs \
&& echo '' > src/lib.rs \
&& cargo build --release --locked \
&& rm -rf src
COPY src ./src
COPY resources ./resources
RUN touch src/main.rs src/lib.rs \
&& cargo build --release --locked \
&& strip target/release/app
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install --no-install-recommends -y ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --system --create-home --uid 10001 rainier
WORKDIR /app
COPY --from=builder /build/target/release/app /usr/local/bin/app
COPY --from=builder /build/resources ./resources
RUN mkdir -p storage/logs storage/mail storage/app /data \
&& chown -R rainier:rainier /app /data
USER rainier
ENV SERVER_HOST=0.0.0.0 SERVER_PORT=8000 APP_ENV=production RUST_LOG=info
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl --fail --silent "http://127.0.0.1:${SERVER_PORT}/health" || exit 1
ENTRYPOINT ["/usr/local/bin/app"]
CMD ["serve"]
Five decisions worth the words:
SERVER_HOST=0.0.0.0. The default is 127.0.0.1, which is unreachable
from outside the container. This is the single most common way a containerised
Rainier app appears to start and then answers nothing.
The stub build. Copying the manifests and compiling a fn main() {} first
puts every dependency in a layer that a change to src/ does not invalidate —
the difference between a twenty-second rebuild and a five-minute one. The
touch afterwards is load-bearing: the stub left cargo's fingerprint newer
than the sources that replaced it, and cargo would otherwise ship the stub.
Debian slim, not distroless or Alpine. Slim keeps the glibc the build linked against and the CA bundle an outbound HTTPS call needs. Alpine means musl and a second toolchain; distroless means no shell, which is a real cost the first time something is wrong in production.
Not root. A container escape is one bug away from being a host compromise, and nothing here needs a privileged port.
The healthcheck curls /health. Running app route:list instead — which
looks tempting, since it needs no extra package — boots a second copy of the
application every thirty seconds, opening a second database pool, and still
says nothing about whether the socket is accepting.
/health is a plain 200 with no database behind it, which is what a liveness
probe should ask: "is this process serving?", not "is every dependency up?". A
readiness probe that checks the database belongs in the orchestrator, where it
can take an instance out of rotation without restarting it.
.dockerignore
/target
/.cargo
.env
*.sqlite
/target because it is tens of gigabytes of artifacts for the wrong platform.
/.cargo because that is where a local config.toml patches the framework to
a sibling checkout that does not exist inside the container — git ignores it,
Docker has to be told separately, and a build with it present cannot resolve
the dependency.
Do not bake .env into the image. Inject the real environment —
it wins over the file.
Continuous integration
The sample ships a pipeline. The parts worth copying are the ones that assert behaviour rather than compilation:
- name: migrate, refuse a rollback, migrate again
env:
DATABASE_URL: "sqlite://ci.sqlite?mode=rwc"
run: |
set -euo pipefail
cargo run --quiet -- migrate | tee first.txt
grep -q "0001_create_users" first.txt
if cargo run --quiet -- migrate:rollback; then
echo "::error::rollback should have refused the irreversible step"
exit 1
fi
cargo run --quiet -- migrate | tee second.txt
grep -q "Nothing to migrate" second.txt
A real file rather than sqlite::memory:, because the point is the ledger
surviving across three processes and an in-memory database dies with its
connection.
- run: cargo run --quiet -- route:list
The cheapest smoke test there is: route:list compiles the router, which
builds every middleware —
so it fails if a group resolves a service no provider binds.
Two more worth having:
- Every feature combination. A
#[cfg]-gated driver arm is not compiled by a default build, so--features rediscan stay broken indefinitely without anything noticing. Matrix the features you ship. - A scheduled run. If the framework is a git dependency,
mainmoving changes your build with no commit of yours to trigger CI. Weekly is enough to find out before a deploy does.
Sizing the image
Cargo features are resolved before anything compiles, and a well-built
application's driver matches are exhaustive — so every compiled driver is
referenced and the linker keeps it. Features are the sizing mechanism, and
cargo rainier features computes the set a deployment needs:
cargo rainier features --env .env.production
It reads two things: the conventional driver variables (CACHE_DRIVER,
QUEUE_DRIVER, MAIL_DRIVER, HASH_DRIVER, STORAGE_DRIVER, KAFKA_TLS) and
your source tree, for the compile-time choices no variable selects.
It cannot see a configuration section
filesystems, queues, databases and cache.stores each name a driver per
entry, and none of them is visible to it. Those sections are declared in Rust by
your own configure, so reading one would mean running the application — which
is what a tool deciding how to build it cannot do.
So a section-declaring application states its drivers twice: once in the section that runs, and once as a variable in whatever environment file the build reads. Get it wrong and the image is built without the feature the entry needs; the failure lands at boot, naming the missing feature, which is at least loud.
Two of those variables are not free to leave lying around:
| Variable | At build time | At runtime |
|---|---|---|
CACHE_DRIVER, QUEUE_DRIVER | needed, if the section declares that driver | a boot failure alongside their section — never both |
STORAGE_DRIVER | needed for an s3 disk | read by nothing — the default disk comes from FILESYSTEM_DISK and every driver from the disk's own declaration |
The first row is the one to get right: keep those two in the build's environment file and out of the running container.
STORAGE_DRIVER is inert at runtime and safe to set anywhere — and it is the
one that silently under-builds, because an s3 disk declared in a
section has nothing else to announce it.
What to run
Three process types, from one binary:
app serve # the web server
app queue:work # one or more workers
app schedule:work # the scheduler, if you have one and no cron
app migrate # once per deploy
schedule:work (and schedule:run) refuse to start in production when a
task declares without_overlapping or on_one_server and the cache is
per-process — every machine would take its own lock, get it, and run. serve
and queue:work are unaffected: they log the same complaint and carry on,
because refusing to serve HTTP over a scheduling concern would be the larger
outage. See Scheduling.
Before you go live
-
APP_DEBUG=false -
a real
DATABASE_URL, migrations applied -
QUEUE_DRIVER=databaseand a worker running -
a real mail transport,
always_tounset in production and set in staging -
APP_URLcorrect, or generated absolute URLs point at localhost -
trust_proxyonly if you are behind one -
SERVER_MAX_BODYsized for your endpoints -
SERVER_REQUEST_TIMEOUTset, and larger than your slowest legitimate route -
CACHE_DRIVERshared if anything scheduleson_one_server—schedule:runrefuses otherwise -
LOG_FORMATproducing what your aggregator parses -
a commit in the build (
GIT_SHA), so/health/versioncan answer - liveness and readiness probes pointed at different endpoints
-
CACHE_DRIVERshared if any route rate-limits credentials — otherwise the limit isn × replicas -
APP_CIPHERmatching what wrote the rows already in the database - secrets injected by the platform, not read from a committed file
-
cargo testandcargo clippy --workspace --all-targetsclean
Up close
This page is the framework’s docs/deployment.md at 7b8cb15.
Found a mistake? Edit it on GitHub.