This guide is the blunt version: Autumn can run as a single-process monolith with almost no config, but that does not automatically make every default safe for multi-replica production.
Baseline
A production-minded Autumn deployment should usually have all of these in place:
AUTUMN_PROFILE=prod/live,/ready, and/startupconnected to platform probes- OTLP telemetry enabled, or at minimum JSON logs
- Redis-backed sessions if more than one web replica will serve the same users
- SMTP-backed mail if the app sends account or notification email
- an explicit migration job before web replicas start
- a clear choice between
#[scheduled]and Harvest for background work
What autumn new Gives You
The scaffold now includes:
Dockerfilewith a multi-stage build.dockerignore- commented probe, telemetry, and Redis session examples in
autumn.toml
That is container scaffolding, not a full cluster deployment. You still need to decide your runtime topology.
Probes
Autumn mounts:
/live/ready/startup/health
Recommended use:
- liveness probe ->
/live - readiness probe ->
/ready - startup probe ->
/startup
Do not point all three at /health just because it was easy in older apps.
Telemetry
Use the [telemetry] section or the AUTUMN_TELEMETRY__* environment
variables to declare service metadata and an OTLP endpoint.
Example:
[telemetry]
enabled = true
service_name = "bookmarks"
service_namespace = "apps"
environment = "production"
otlp_endpoint = "http://otel-collector:4317"
protocol = "Grpc"
If you are not ready for OTLP yet, force log.format = "Json" so your logs are
at least machine-readable.
What you get automatically
With the telemetry-otlp cargo feature enabled and telemetry.enabled = true:
- W3C Trace Context propagation. Incoming
traceparent/tracestateheaders are extracted and attached to a server span; the current context is injected back into the response headers so callers can continue the trace. No manual middleware setup required. - Scheduled task traces. Each invocation of a
#[scheduled]function runs inside a fresh root span (scheduled_task/task=<name>) so every run shows up as its own trace in your APM. - Database spans. The
Dbextractor opens adb.connection.acquirespan tagged withdb.system=postgresqlwhose scope covers the lifetime of the pooled connection — Diesel activity performed through it appears as a child of the request span in Jaeger / Tempo / Datadog.
File Uploads
The Multipart extractor's save_to(path) primitive writes to the local
disk of whichever pod handled the request — invisible to the next replica
and gone on the next deploy. For multi-replica deployments that accept
user-uploaded files (avatars, attachments, generated reports), enable the
storage feature and pick the S3 backend:
[storage]
backend = "s3"
[storage.s3]
bucket = "my-app-uploads"
region = "us-east-1"
In prod, backend = "local" fails fast at startup unless you set
storage.allow_local_in_production = true — same pattern as the session
backend's memory-in-prod check.
See storage.md for the full backend, configuration, and production-checklist story.
Sessions
In-memory sessions are fine for local development and single-process demos. They are the wrong default for horizontally scaled apps.
Use:
[session]
backend = "redis"
[session.redis]
url = "redis://redis:6379"
key_prefix = "my-app:sessions"
The prod profile warns when you keep backend = "memory" without explicitly
acknowledging it via session.allow_memory_in_production = true.
The mail cargo feature gives apps a Mailer extractor and log/file/SMTP
transports. Development defaults to transport = "log" so password-reset and
signup flows can be built before SMTP exists. Production rejects log transport
unless mail.allow_log_in_production = true is set.
Use SMTP in production:
[mail]
transport = "smtp"
from = "Acme <noreply@example.com>"
[mail.smtp]
host = "smtp.example.com"
port = 587
username = "apikey"
password_env = "SMTP_PASSWORD"
tls = "starttls"
For durable retries across replicas, register a durable
MailDeliveryQueue via
AppBuilder::with_mail_delivery_queue before .run() (see the Mail Guide
for the trait definition and an outbox example). Without one, prod startup
fails unless you explicitly set
mail.allow_in_process_deliver_later_in_production = true, which
acknowledges the in-process Tokio fallback. The fallback is fine for local
development and small single-process deployments but is not durable across
restarts or replicas.
When email dispatch is coordinated with DB writes, use
Db::tx for the database side so the write set commits or
rolls back atomically.
Background Work
Use #[scheduled] when:
- the task is small and in-process
- duplicate execution per replica is acceptable or explicitly coordinated
- you do not need durable retries or workflow history
Use Harvest when:
- the task must survive restarts
- retries and visibility matter
- work should be coordinated across replicas
- you are really describing a workflow, not a cron callback
Migration Jobs
For multi-replica deployments, do not rely on each web process racing to apply migrations. Run migrations once as a dedicated job, then start the web deployment after it succeeds.
The migration job must target the primary/write role:
AUTUMN_DATABASE__PRIMARY_URL="postgres://user:pass@primary:5432/app" autumn migrate
DATABASE_URL still works for single-primary apps, but naming
AUTUMN_DATABASE__PRIMARY_URL keeps the deployment contract explicit. Keep
auto_migrate_in_production = false on web replicas unless you are deliberately
running a single-process deployment.
Database Topology
Use the [database] section to declare the shape:
[database]
primary_url = "postgres://user:pass@primary:5432/app"
replica_url = "postgres://user:pass@replica:5432/app" # optional
primary_pool_size = 10
replica_pool_size = 5
replica_fallback = "fail_readiness" # or "primary"
auto_migrate_in_production = false
Db, transactions, advisory locks, scheduled-task coordination, and migrations
use the primary role. Read-oriented code may use the replica pool when one is
configured; if the replica is missing or stale, choose one deterministic
behavior: fail readiness (fail_readiness) or explicitly fall back to the
primary (primary).
autumn doctor --strict checks the topology contract: missing primary role,
unreachable primary/replica endpoints, unsafe production migration ownership,
and stale replica migration versions. Diagnostics name the failing role and
redact credentials.
The distributed bookmarks example uses this shape explicitly with a primary, streaming replica, one migration job, two web replicas, and a readiness gate that fails while the replica has not replayed the latest Diesel migration.
Shared Cache
In-process Moka caches are the zero-config default and are perfect for local development. Each replica holds its own independent store, so:
#[cached]may return stale data depending on which replica answers.CacheResponseLayerinvalidations on one pod are invisible to peers.
For multi-replica production deployments, enable the Redis backend via
autumn-cache-redis:
# autumn.toml
[cache]
backend = "redis"
[cache.redis]
url = "redis://redis:6379"
key_prefix = "myapp:cache"
// main.rs
use autumn_cache_redis::RedisCachePlugin;
autumn_web::app()
.plugin(RedisCachePlugin::new())
.routes(routes![...])
.run()
.await;
autumn-cache-redis requires the autumn-cache-redis crate:
# Cargo.toml
[dependencies]
autumn-cache-redis = "0.4"
CacheResponseLayer::from_app(&state) returns Some(layer) wired to the
configured Redis backend when one has been registered, or None when running
with the default per-function Moka caches.
The memory default produces a startup warning in the prod profile — the
same pattern as sessions and file storage.
Concurrent Writes
The lost-update problem
With more than one replica, two requests can read the same row, compute independent changes, and both write back — the second write silently overwrites the first. No error is raised, no conflict is detected, data is lost.
Optimistic concurrency via #[lock_version]
Add the attribute to any model field named lock_version:
#[autumn_web::model]
pub struct Article {
#[id]
pub id: i64,
pub title: String,
pub body: String,
#[lock_version]
pub lock_version: i32,
#[default]
pub created_at: chrono::NaiveDateTime,
#[default]
pub updated_at: chrono::NaiveDateTime,
}
The framework then:
- Stores the current
lock_versionas a counter column in the database. - Requires the client to send the expected version alongside its update
payload (the generated
UpdateArticlestruct carries this automatically). - On write, issues an atomic
UPDATE … WHERE id = $1 AND lock_version = $2and increments the counter only if the row matched.
If the row was updated between the client's read and its write — i.e. the
stored version is no longer what the client expected — the UPDATE matches zero
rows and the repository returns RepositoryError::Conflict, which the
framework maps to HTTP 409 with an RFC 7807 problem body:
{"type": "about:blank", "status": 409, "title": "Conflict", "detail": "..."}
For htmx clients, the framework also emits the response header:
HX-Trigger: {"autumn:conflict":true}
Your client-side script can listen for that event and re-fetch the current version before letting the user resubmit.
A handler that catches the conflict and signals a retry:
async fn update_article(
State(repo): State<ArticleRepository>,
Path(id): Path<i64>,
Form(input): Form<UpdateArticle>,
) -> Response {
match repo.update(id, input).await {
Ok(article) => Redirect::to(&format!("/articles/{}", article.id)).into_response(),
Err(RepositoryError::Conflict { .. }) => {
// Re-fetch the current version and tell the user to retry
let current = repo.find(id).await.unwrap();
(StatusCode::CONFLICT, EditTemplate { article: current, conflict: true })
.into_response()
}
Err(e) => e.into_response(),
}
}
Optimistic concurrency is the right default for most web applications: reads are cheap, conflicts are rare, and throughput scales well.
Pessimistic concurrency via with_lock
For low-contention but high-consequence writes — think inventory deductions,
financial ledger entries, or seat reservations — you cannot afford to retry
after detecting a conflict because another request may have already acted on
the same data. Use with_lock to acquire a database-level advisory or
row-level lock before reading:
repo.with_lock(id, |row, conn| async move {
// `row` is the freshly locked Page; `conn` is the transaction connection.
// Any writes here are serialized against other `with_lock` callers for
// the same `id`.
row.seats_remaining -= 1;
diesel::update(seats::table.find(row.id))
.set(seats::seats_remaining.eq(row.seats_remaining))
.execute(conn)
.await?;
Ok(row)
}.scope_boxed()).await
The closure runs inside a transaction. The lock is released when the transaction commits or rolls back.
Trade-offs
| Optimistic | Pessimistic | |
|---|---|---|
| Throughput | High — no blocking between readers | Lower — concurrent writers queue |
| Latency | Low on the happy path; a retry adds one round trip | Consistently higher; each writer waits for the lock |
| Complexity | Low — framework handles version checks | Moderate — closure-based API, must reason about deadlocks |
| Best for | Typical CRUD, forms, wiki edits, profile updates | Inventory, ledger, seat/slot reservation, anything where retry is unsafe |
Cache invalidation after writes
after_update hooks receive a MutationContext that accepts
ctx.invalidate("key") calls to declare cache keys that should be evicted
after the write commits. This is coordinated with the shared-cache integration
(#535) so that the correct backend — Moka or Redis — is targeted regardless of
which replica processed the write:
async fn after_update(&self, ctx: &mut MutationContext, page: &Page) -> AutumnResult<()> {
ctx.invalidate(format!("pages:{}", page.id));
ctx.invalidate("pages:all");
Ok(())
}
Minimal Deployment Checklist
Before calling an Autumn app "cloud ready", verify:
- probes target
/live,/ready, and/startup - logs or traces land in your collector
- sessions are externalized if replicas > 1
- cache uses the Redis backend if replicas > 1
- file uploads use the
S3blob store if replicas > 1 - mail uses SMTP, not log/file transport
- migrations run before web rollout
- background jobs use the right runtime model
- multi-replica write paths use
#[lock_version](optimistic) orwith_lock(pessimistic) to prevent lost updates - the generated container image builds without manual template surgery