Deployment
A Riverbase service is one static binary plus Postgres. Optionally it also needs a message broker (NATS or Redis) and an identity provider.
Service modes
- Coupled
- Split
One process serves command and query routes for its domains. This is the right default: one deployment, one pool, and reads immediately see the process's own writes.
app.mount_domain(domain).await?; // commands + queries + custom routes
The command side and the query side run as separate binaries, so they scale independently. Both point at the same database. The query side never opens a write transaction.
use riverbase_core::domain::Domain;
use riverbase_http::web::CommandAppState;
let mut app = RiverbaseApp::init("Todo commands", "Command side of the todo domain")?;
let runtime = DomainRuntime::from_config(app.config()).await?;
let domain = TodoDomain::<PgDataStore>::spawn(&runtime).await?;
app.mount_command(CommandAppState::new(domain.command_dyn())).await?;
app.bind(std::env::var("RIVERBASE_COMMAND_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8081".into()));
app.serve().await?;
The query binary does the same with QueryAppState::new(domain.query_dyn()), mount_query, and
RIVERBASE_QUERY_BIND_ADDR (default 0.0.0.0:8082). Route POST …:post and
…:exec to the command service, and GET ….list / .item / .meta to the query service.
Migrations at boot
Every service binary should support --migrate-only:
let runtime = DomainRuntime::from_config(app.config()).await?; // framework migrations
let domain = TodoDomain::<PgDataStore>::spawn(&runtime).await?.into_arc(); // domain migrations
if RiverbaseApp::migrate_only() {
return Ok(());
}
Run it as a one-off job (a Kubernetes Job, an init container, or a release phase) before rolling
out new replicas. Each crate tracks its own migrations in its own schema, so the framework, the
extension crates, and your domains migrate independently.
Container image
FROM rust:1.83-bookworm AS build
WORKDIR /src
COPY . .
RUN cargo build --release -p todo-service
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libpq5 && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/todo-service /usr/local/bin/todo-service
ENV RIVERBASE_BIND_ADDR=0.0.0.0:8080 RIVERBASE_LOG_FORMAT=json
EXPOSE 8080
ENTRYPOINT ["todo-service"]
The image needs libpq for Diesel's Postgres driver. Pass configuration through RIVERBASE_*
variables or mount a riverbase.toml and point RIVERBASE_CONFIG at it.
What runs alongside
| Component | Needed when | Notes |
|---|---|---|
| PostgreSQL 14+ | Always | State, audit, outbox, tracker, and extension tables |
| NATS | Production messaging (the default bus) | Or Redis, or Postgres LISTEN/NOTIFY for small setups |
| Keycloak or another OIDC IdP | Authenticated APIs | Riverbase validates tokens; it does not store users |
Workers (riverbase_task) | Post-commit side effects | Scale independently of the API |
Scaling notes
- Replicas: API replicas are stateless. Sessions are signed cookies, and realtime fan-out goes
through the
StreamBus. - Pool sizing:
dbpool_max_size(default 32) multiplied by the replica count must fit Postgres'smax_connections, leaving room for workers and migrations. - Command concurrency:
command_pool_sizelimits in-flight commands per engine and process. Raise it for I/O-heavy handlers, but remember that correctness comes from the database, not the pool. - Outbox: every replica runs a publisher. Rows are claimed with
FOR UPDATE SKIP LOCKED, so replicas do not pick up the same row at once. Delivery is still at least once, so consumers must be idempotent.
Health
Expose a liveness route, either a domain! routes probe or a public route registered with a
reason. RiverbaseApp also tracks migration readiness (mark_migrations_ready, readiness()) for a
readiness probe.