Deployment

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

SettingDevelopment defaultProduction
APP_DEBUGtrue in .env.examplefalse
APP_ENVlocalproduction
DATABASE_URLsqlite::memory:a real database
QUEUE_DRIVERsyncdatabase (or your own)
MAIL_DRIVERloga real transport
APP_URLhttp://localhost:8000your actual origin
server.trust_proxyfalsetrue only behind a proxy you control
LOG_FORMATauto → prettyauto → JSON, or set it
SERVER_REQUEST_TIMEOUT0 (off)a ceiling your slowest route clears
CACHE_DRIVERmemoryshared, 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:

  1. QUEUE_DRIVER=database
  2. the queue's tables — merge them into your migrator
  3. 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

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 redis can stay broken indefinitely without anything noticing. Matrix the features you ship.
  • A scheduled run. If the framework is a git dependency, main moving 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:

VariableAt build timeAt runtime
CACHE_DRIVER, QUEUE_DRIVERneeded, if the section declares that drivera boot failure alongside their section — never both
STORAGE_DRIVERneeded for an s3 diskread 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=database and a worker running
  • a real mail transport, always_to unset in production and set in staging
  • APP_URL correct, or generated absolute URLs point at localhost
  • trust_proxy only if you are behind one
  • SERVER_MAX_BODY sized for your endpoints
  • SERVER_REQUEST_TIMEOUT set, and larger than your slowest legitimate route
  • CACHE_DRIVER shared if anything schedules on_one_server — schedule:run refuses otherwise
  • LOG_FORMAT producing what your aggregator parses
  • a commit in the build (GIT_SHA), so /health/version can answer
  • liveness and readiness probes pointed at different endpoints
  • CACHE_DRIVER shared if any route rate-limits credentials — otherwise the limit is n × replicas
  • APP_CIPHER matching what wrote the rows already in the database
  • secrets injected by the platform, not read from a committed file
  • cargo test and cargo clippy --workspace --all-targets clean

Up close

cargo rainier features reads the production env file and the source tree and prints the exact feature set to build, with the reason for each.
One binary, four processes: serve, queue:work, schedule:work and migrate are subcommands of the same build.

This page is the framework’s docs/deployment.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.