This guide walks you from a fresh autumn new project to a production-shaped
container running against a real Postgres database. Every command is verbatim;
no file editing is required to reach a running container.
Target time: under 10 minutes on a machine with Docker and a working internet connection.
This promise is machine-verified, not aspirational. The
release-image-bootCI gate scaffolds a fresh project, runs every command on this page (autumn new→autumn release init --force→docker build→ one-shotautumn migrate→ boot), and fails the build unless the container answersGET /healthandGET /actuator/healthwith200within the documented startup budget. It covers both the barerelease initimage and the--target docker-composestack, so the deployment scaffold can never silently rot — a base-image bump, a missing system lib, or an asset-path drift is caught in CI, not by a user's first failed production deploy. Seescripts/check-release-image-boot.shfor the build-and-boot harness.
Prerequisites
- Rust 1.88.0+ with
cargo - Docker (or Docker Desktop) —
docker --version - PostgreSQL accessible at a connection string you control (local or remote)
- The
autumnCLI -cargo install autumn-cli --version 0.6.0
Push-button deploy to your own server (autumn deploy)
autumn deploy takes a fresh project to a live, zero-downtime service on a
single Linux VPS you control — no Dockerfile, no container registry, no PaaS
account. It uploads a single embedded binary, supervises it with systemd behind
a reverse proxy, migrates before cutover, health-gates on /ready, and flips
traffic atomically. Re-running it is a zero-downtime redeploy; one command rolls
back.
This is the primary deployment path. Two alternatives remain documented below and are better fits in specific cases:
- Deploy to fly.io — a managed platform (machines,
built-in metrics scraping,
fly deploy). - The container path (Step 1 through How the production image works) — a portable OCI image you run on Kubernetes, ECS, Nomad, or any Docker host.
HTTPS/TLS. kamal-proxy fronts your app and listens on your configured public HTTP port (
server.port) and, by default, 443 — its HTTPS listener is always bound and cannot be disabled. By defaultautumn deployprovisions no certificate for your app, so nothing is served over HTTPS until you opt in. Set[deploy.tls] enabled = trueandhost = "app.example.com"inautumn.tomlto haveautumn deploypass--host/--tlson your app's kamal-proxy route/flip, so kamal-proxy provisions an automatic Let's Encrypt certificate for that host on-demand and terminates TLS for it on its always-bound 443 listener. This needs noserver.portchange — issuance uses TLS-ALPN-01 on the already-bound 443, so it works on both the first deploy and a later redeploy. Settingserver.port = 80is recommended (it is the default) so the proxy also serves HTTP on 80 for the HTTP→HTTPS redirect, but it is not required. An external TLS terminator sharing the same host is not supported (kamal-proxy always binds 443 and cannot release it); run such a terminator on a separate host in front of the deploy host instead. Either way TLS terminates at the proxy: don't enable in-process[server.tls]/ACME on a deploy-managed app — deploy binds each slot to a private loopback HTTP port that the readiness gate and kamal-proxy target over plain HTTP, so a TLS listener there breaks its health checks. See the TLS & HTTPS guide for the full picture, including in-process TLS for self-run apps.
Preconditions
Everything except two host prerequisites is automated by autumn deploy up:
- Key-based SSH access as
root(or an equivalently privileged account) to a stock Ubuntu LTS (or other systemd) host. The deploy runs non-interactively (BatchMode=yes) as the configured user ([deploy] user, defaultroot) — no password prompts, so your SSH key must already be authorized on the target.autumn deployruns its remote steps directly over SSH, withoutsudo: it writes units into/etc/systemd/system/, runssystemctl(daemon-reload/enable/restart/disable) andsystemd-run, and writes under the app directory. The[deploy] usermust therefore beroot(the default) or an account that already holds those permissions directly. A plain non-root SSH login passes preflight (which only checks reachability and secrets, not privilege) and then fails once it tries to write the unit or invokesystemctl. - The
kamal-proxybinary present at/usr/local/bin/kamal-proxy. The deploy writes and supervises the proxy's systemd unit, but does not download the binary itself — install it once as part of host bootstrap. (See Limitations.) - A local Rust toolchain to build the release binary (
autumn build --embed).
The reverse proxy, install directories, systemd units, release layout, and the secret env file are all created for you.
First deploy: from autumn new to a live app
# 1. Scaffold a project (its package name becomes the deploy app name).
autumn new myapp
cd myapp
Point the deploy at your server by adding a [deploy] section to autumn.toml:
[deploy]
host = "203.0.113.10" # SSH-reachable IP or hostname of your VPS (required)
# user = "root" # SSH user (default: root)
# ssh_port = 22 # SSH port (default: 22)
# app_dir = "/srv/autumn/myapp" # remote install dir (default: /srv/autumn/<app_name>)
# readiness_timeout_secs = 60 # /ready window before rollback (default: 60)
# keep_releases = 3 # prior releases retained for rollback (default: 3)
Every key also has an environment override (AUTUMN_DEPLOY__HOST,
AUTUMN_DEPLOY__USER, AUTUMN_DEPLOY__SSH_PORT, …) if you prefer to keep the
host out of the file.
Put these two values in a project .env (git-ignored — never committed). The
deploy reads them and writes only them to a 0600 env file
(app_dir/shared/autumn.env) on the server:
# .env
AUTUMN_SECURITY__SIGNING_SECRET=<paste `openssl rand -hex 32` here>
# Only if your app is database-backed:
AUTUMN_DATABASE__URL=postgres://user:pass@db-host:5432/myapp_prod
The signing secret, the database URL, and the profile selector are deployed.
autumn deployserializes exactly three variables to the host env file:AUTUMN_SECURITY__SIGNING_SECRET, — for database-backed apps —AUTUMN_DATABASE__URL, andAUTUMN_ENV(the deploy profile,prodby default — see below). Any other runtime secret (OAuth client secrets, SMTP credentials, a Redis URL, object-storage keys, etc.) placed in.envis not transferred to the server, so those features fail in production unless you provision the secret on the target host yourself. The env file is rebuilt from scratch on everydeploy up, so hand-adding entries toapp_dir/shared/autumn.envis not durable — they are overwritten on the next deploy; provision such secrets out of band on the host (or via the systemd unit environment) and re-apply them after each deploy.
autumn deployruns the app under the production profile by default. The deploy writesAUTUMN_ENV=prodinto the host env file, so the deployed app boots under theprodprofile and its production smart-defaults apply — strict CORS, minimal actuators, prod CSRF/session hardening, and strict signing-secret enforcement. To deploy a non-production target, set[deploy] profile = "staging"inautumn.toml(orAUTUMN_DEPLOY__PROFILE=staging); the deploy writes that value asAUTUMN_ENVinstead.
Your
autumn.tomlis deployed alongside the binary.autumn deploy upuploads your project'sautumn.toml(and, when present, the profile siblingautumn-<profile>.toml) into the per-release directory — next to the binary it shipped with — and setsAUTUMN_MANIFEST_DIRto that release dir in the systemd unit, so at startup the app loads the same non-secret configuration you tested locally — auth, the jobs and scheduler backends, health/telemetry paths, CORS, signing-secret rotation (security.signing_secret.previous_secrets), and so on — instead of falling back to built-in defaults (fixes #1952). Coupling the config to the release (rather than a single shared dir) means a rollback loads the rolled-back release's own config — never the latest deploy's — and removing a local override and redeploying no longer leaves a stale one loaded, because a fresh release dir carries only the manifests uploaded that deploy. The profile sibling is picked the same way the host runtime picks it: the deploy normalizes the profile and uploads the first matchingautumn-<profile>.toml(e.g.profile = "Production"uploadsautumn-production.toml, matching the file the app loads first). The raw manifest is uploaded, not a flattened copy, so the app still applies its[profile.<AUTUMN_ENV>]overlay at runtime; the manifest is re-uploaded on everydeploy upso the config on the server always matches the shipped binary. If noautumn.tomlis found in the project directory, the deploy prints a loud warning and the app runs built-in defaults for all non-secret settings. Secrets never go in the manifest — the manifest is owner-only (0600), so any inline config secrets are never exposed to other local accounts, while the signing secret, database URL, andAUTUMN_ENVcontinue to travel only in the0600host env file (app_dir/shared/autumn.env), which overrides theautumn.tomlat load time.
Generate the signing secret once and store it somewhere durable:
openssl rand -hex 32
Run the preflight — it checks SSH reachability, the signing secret, the database
URL (when the app is DB-backed), and, via an offline scan of your local
migrations/ directory, that every migration file is safe for a rolling deploy.
That scan does not consult the database, so it does not distinguish already-applied
from pending migrations — an old destructive migration file left in the directory
fails the check even after it has been applied. Keep migrations/ rolling-safe
(remove or adjust an already-applied destructive migration if it trips the scan).
The preflight exits non-zero if anything fails, so nothing touches the server
until it is green:
autumn deploy check # doctor --online runs the same graders (plain doctor skips network probes)
Build the single embedded binary, then deploy:
autumn build --embed # produces target/release/myapp (assets + i18n baked in)
autumn deploy up
autumn deploy up re-runs the full preflight, aborts before any remote call if
it fails, then on this first run:
- installs and supervises kamal-proxy on the public port
(
server.port, default3000), - creates the release +
shareddirectories underapp_dir, - uploads the binary into a timestamped release dir (
0755), - writes the signing secret, database URL, and
AUTUMN_ENV(the profile,prodby default) toapp_dir/shared/autumn.env(0600, sourced by systemd — never printed, never on a command line; rebuilt each deploy), - writes the app's systemd unit (bound to a private
127.0.0.1port), pointscurrentat the release, and starts it, - health-gates on
GET /readywithinreadiness_timeout_secs, then - routes the proxy at the freshly-ready release.
On success it prints:
✅ Deploy complete. Roll back with `autumn deploy rollback`.
Verify it is serving (the public port is your configured server.port):
curl http://203.0.113.10:3000/health # -> {"status":"ok", ...}
curl http://203.0.113.10:3000/ready # readiness probe used during cutover
Zero-downtime redeploy
Ship a new version by re-running the exact same command:
autumn build --embed
autumn deploy up
On a host that is already serving, up performs a blue/green cutover with no
dropped requests:
- stands the new release up on the idle loopback slot while the current release keeps serving,
- runs pending migrations on the host before cutover (
AUTUMN_MIGRATE=1one-shot) — a failed migration aborts here with the old version still live, - health-gates the candidate on
/readywithin the readiness window, - does an atomic kamal-proxy upstream flip old → new,
- drains and stops the old release, then
- prunes old releases, keeping the most recent
keep_releases(default3) plus any rollback targets.
Because the run stops at the first failure, a bad migration or a candidate that
never reports /ready leaves the previous version serving and tears the
candidate down automatically — there is no half-deployed state serving traffic.
Rollback
Roll back to the previous release on demand:
autumn deploy rollback
This resolves the previous release on the host, brings its slot back up, flips
the proxy back to it (health-gated on /ready), repoints current, and
re-probes /ready. It fails loudly and non-zero when there is no previous
release to return to.
Rollback runs the same local preflight as
deploy upfirst. Before it makes any remote call — before it even resolves the previous release on the host —autumn deploy rollbackruns the identical local preflight graders (signing secret, database URL, migrate-safety) and aborts non-zero if any fail. So it needs the same local inputs as a deploy — your project's.env/signing secret and database URL, and themigrations/dir — available wherever you invoke it: an emergency rollback from a bare CI checkout or a machine without the project's secrets fails preflight before it ever reaches the host. Keep the deploy inputs available where you would run a rollback.
Inspect the plan without touching the server
autumn deploy plan
plan is a pure dry-run: it prints a representative systemd unit and the
ordered zero-downtime rollout steps for review, without connecting to the host.
The printed unit is illustrative — it mirrors the shape of what gets installed
(User, working directory, EnvironmentFile, restart policy) but is not
byte-for-byte what a real deploy up writes. Live deploys write slot-specific
units named {service}-blue.service / {service}-green.service, each pinned to
its own release directory and bound to a private 127.0.0.1 port, so the blue
and green slots can run side by side for the health-gated cutover while the proxy
owns the public port.
The step order is illustrative too: the list shows the overall rollout shape
for review, but its exact order can differ from a live deploy up — for example,
plan lists the migration step before starting the candidate, whereas a real
redeploy starts the candidate first and then runs the pre-cutover migration. Use
plan to review the shape and what happens, not as a byte-exact order or unit
reference.
Where secrets live
The signing secret and database URL are written only to
app_dir/shared/autumn.env on the target, with mode 0600, and are sourced by
the systemd unit via EnvironmentFile. They are never inlined into the
world-readable unit, never placed on a command line, and never printed to logs
or error messages.
Troubleshooting
- Preflight is failing. Run
autumn deploy check(orautumn doctor --online) — each failing grader prints the exact problem and a one-line fix (missing[deploy] host, unreachable SSH port, missing/weak signing secret, missing writable database URL, or an unsafe migration in your localmigrations/directory).autumn deploy checkalways probes SSH reachability; plainautumn doctorskips network probes, so useautumn doctor --onlineto include the SSH-reachability grader. release binary not found …— runautumn build --embedfirst;deploy upuploads the pre-built binary, it does not build for you.- Nothing answers on the public port — confirm the app's
server.portmatches the port you are curling, and that the host firewall allows it.
Limitations and known gaps
- The
kamal-proxybinary must be installed on the host (at/usr/local/bin/kamal-proxy) before the first deploy.autumn deploy upconfigures and supervises the proxy but does not download its binary — provision it as part of host bootstrap. - Only the signing secret, database URL, and profile selector are written to
the host env file.
autumn deployserializes justAUTUMN_SECURITY__SIGNING_SECRET, (for database-backed apps)AUTUMN_DATABASE__URL, andAUTUMN_ENV(the deploy profile,prodby default); any other runtime secret (OAuth/SMTP/Redis/storage/etc.) must be provisioned on the target separately. The file is rebuilt on everydeploy up, so hand-added entries do not persist. - Your project's
autumn.tomlis deployed (#1952).autumn deploy upuploads yourautumn.toml(and the profile siblingautumn-<profile>.tomlwhen present) into the per-release directory at mode0600(owner-only, so secrets are never exposed to other local accounts) and setsAUTUMN_MANIFEST_DIRto that release dir in the systemd unit, so the app loads the same non-secret configuration (auth, jobs and scheduler backends, health/telemetry paths, CORS, signing-secret rotationprevious_secrets, etc.) you tested locally rather than falling back to built-in defaults. Because the manifest is coupled to the release, a rollback loads the rolled-back release's own config and removing a local override then redeploying doesn't leave a stale one lingering. The profile sibling is normalized and chosen exactly as the host runtime chooses it (e.g.profile = "Production"uploadsautumn-production.toml). The manifest is re-uploaded on everydeploy up; secrets never go in it — they stay in the0600host env file, which overridesautumn.tomlat load time. When noautumn.tomlis found locally the deploy prints a loud warning and the app runs built-in defaults. - Migrations run on redeploys, not on the very first deploy. The pre-cutover
migration one-shot is part of the zero-downtime redeploy path; the initial
deploy upstands the release up and health-gates it. For a database-backed app, ensure the schema is applied (e.g. a follow-upautumn deploy up, or an out-of-bandautumn migrateagainst the primary) before relying on DB routes. - Single host.
[deploy] hosttargets one server; there is no multi-host fan-out. For horizontally scaled setups behind a shared load balancer, see Multi-replica setup. - Remote state is updated step-by-step, not transactionally (#1938). The
currentsymlink and the live/previous-release markers are written by individual SSH commands, so an interrupted deploy can leave state mid-flight; a subsequentautumn deploy uporautumn deploy rollbackis the intended recovery. To keep the most damaging drift self-correcting, eachautumn deploy upreconciles thelive-slotmarker against the live proxy at deploy-start: the same probe that decides first-vs-redeploy also readskamal-proxy list, and when the proxy is unambiguously serving a slot the marker disagrees with (a stale marker from an interrupted previous run), the deploy treats the proxy as authoritative — it plans the cutover onto the genuinely idle slot (so it never restarts the live one), warns loudly about the disagreement, and repairs the marker on disk. If the proxy signal is absent or unclear it falls back to the marker exactly as before, so the reconcile never changes a healthy deploy.
MediaMTX host provisioning ([media])
An app that uses the autumn-media plugin
(RTMP/WHIP ingest, HLS/WebRTC playback, recording) can have autumn deploy
provision MediaMTX as a host systemd unit on the same box — exactly as it
already provisions kamal-proxy (#2051). It is opt-in and disabled by default,
so a non-media project is byte-for-byte unaffected.
Enable it in autumn.toml (or a profile / autumn-<profile>.toml layer):
[media.mediamtx]
enabled = true # off by default; the controller is a no-op when false
# recordings_dir = "/var/lib/mediamtx/recordings"
# record_delete_after = "72h" # MediaMTX recordDeleteAfter retention window
# webrtc_additional_hosts = ["my-app-mediamtx.example.com"] # extra WebRTC ICE hosts
# The listen ports below default to MediaMTX's standard values; override only if
# you also change the app-side *_base URLs to match.
# api_port = 9997 # control API
# rtmp_port = 1935 # RTMP ingest only (OBS / RTMP encoders)
# hls_port = 8888 # HLS playback
# webrtc_port = 8889 # WebRTC: WHIP publish (browser + room) AND WHEP/WebRTC playback
# playback_port = 9996 # recording playback
# webrtc_local_udp = 8189
# config_path = "/etc/mediamtx/mediamtx.yml" # where the rendered config is written
# binary_path = "/usr/local/bin/mediamtx" # host bootstrap installs it; deploy does not download it
# unit_name = "mediamtx" # systemd unit name (no .service suffix)
[media.ffmpeg]
# bin = "/usr/bin/ffmpeg" # concrete path; verified by the deploy-time FFmpeg preflight
When enabled = true, autumn deploy up:
- Runs four fail-closed host preflight checks before touching the host —
FFmpeg resolves (the concrete
[media.ffmpeg] bin), the MediaMTX binary is executable, the recordings directory is writable, and the MediaMTX ports are free — plus a pure-config precheck that the configured MediaMTX listener ports are distinct, and aborts the deploy if the host cannot serve media, rather than shipping a half-provisioned box. One caveat on the FFmpeg check: only a concrete literal[media.ffmpeg] binis probed and fail-closed here; an env/interpolation-indirected path (an empty value, or one carrying a${...}placeholder such as${AUTUMN_MEDIA__FFMPEG__BIN}) is resolved by the deployed service from its own environment, so it is deferred to runtime — surfaced as a non-blocking warning that does not abort the deploy. These checks require a live host executor and run only atdeploy up. - After the app cutover succeeds, renders
mediamtx.yml(LL-HLS window, fmp4 recording underrecordings_dir, WebRTC config, and a~^room/.+$path matcher for autumn-media Rooms) plus the systemd unit, then runsdaemon-reload && enable --now && restart.
autumn deploy plan is a pure dry-run: it surfaces the media unit, its
provisioning steps, the names of the host preflight checks that will run at
deploy up, and the CSP origins your app must allow — but it holds no host
executor, so it does not probe MediaMTX/FFmpeg or validate ports remotely. The three browser-facing MediaMTX origins
(WebRTC :8889, HLS :8888, playback :9996) must appear in your
connect-src / media-src (and frame-src for WebRTC) CSP; in production they
collapse to your public MediaMTX origin, and your object-store origin must also
be allowed in media-src for recorded playback.
strict_configinteraction. The[media]table is plugin-owned — it is not part of autumn-web'sAutumnConfigschema.autumn deployreads the[media.mediamtx]/[media.ffmpeg]subtree straight from the mergedautumn.toml(base ← inline[profile.<name>]←autumn-<profile>.toml), so that media-subtree read never itself routes through the strict schema. But that does not make strict config deploy-safe. Before it ever reads the raw[media]subtree,deploy::runcallsAutumnConfig::load()— the strict loader (autumn-cli/src/deploy.rs, ahead ofload_media_host_config) — for every subcommand. So if you turn on autumn-web's strict config validation ([server] strict_config = true, orAUTUMN_SERVER__STRICT_CONFIG=1) and keep a top-level[media]table in that strict-loaded config, the[media]table is flagged as an unknown top-level key and hard-fails during that load (unknown top-level keys were already strict pre-#1890, so this fails even withoutstrict_config_enforce_all). That means both:
- the app runtime fails to boot, and
autumn deploy plan/deploy upalso exit during config load on the unknown[media]key — they never reachload_media_host_configand never provision MediaMTX, because the strictAutumnConfig::load()runs first.Workaround: treat
strict_configand a top-level[media]table as mutually exclusive today — don't enablestrict_configwhile the strict-loaded config carries a top-level[media]table (the validator has no knowledge of the plugin's[media]section, on either the app-boot or the deploy path).
Deferred (host-bootstrap prerequisites, not done by autumn deploy):
installing/pinning the MediaMTX binary itself (like the kamal-proxy binary, it is
a host-bootstrap step); creating and permissioning the recordings_dir
(default /recordings) so the media user can write to it — autumn deploy only
mkdirs the MediaMTX config file's parent directory, so the fail-closed
recordings-dir preflight (test -d && test -w) aborts deploy up when the
directory is missing or not writable; and wiring the four host preflight checks
into the offline autumn doctor CLI (they run only in the executor-holding
deploy up path today; deploy plan names them but never executes them).
How the deploy path is validated in CI
Two layers exercise the real autumn deploy lifecycle over real ssh/scp +
systemd + kamal-proxy — nothing is mocked:
-
Container e2e (every CI run):
autumn-cli/tests/deploy_e2e.rsdrives first deploy, zero-downtime redeploy, on-demand rollback, and forced-failure auto-rollback against a privileged systemd+sshd container. A container cannot power-cycle, prep a stock host from scratch, or reproduce a real pam_systemd session, so those are deferred to the real-VPS job below. -
Real-VPS validation (opt-in): the
Deploy real-VPS validationGitHub Actions workflow provisions a throwaway Hetzner Cloud VM from a stock Ubuntu image and runs the same lifecycle assertions plus the four VM-only checks: a real kernel reboot (app + kamal-proxy come back serving), bare-host prep from scratch, the<15 minonboarding wall-clock metric, and kamal-proxy control-socket fidelity under real pam_systemd (XDG_RUNTIME_DIR=/run/user/0). It is manually triggered (workflow_dispatch) or nightly only — it never runs on pull requests or pushes, because it costs a real VM and must never block or bill routine CI. It always destroys the VM on exit (even on failure), so nothing lingers.The only credential it needs is
HCLOUD_TOKEN. The workflow reads it from the environment (supply it however you configure Actions env — e.g. a repository secret under Settings → Secrets and variables → Actions); the job's first step fails with a clear message if it is unset, and no credential is ever hardcoded:Env var What it is HCLOUD_TOKENA Hetzner Cloud API token (Read & Write) from the Hetzner console → project → Security → API Tokens. Used to provision and destroy the throwaway VM. Required. AUTUMN_DEPLOY_SIGNING_SECRETOptional. The app signing secret ( AUTUMN_SECURITY__SIGNING_SECRET, 64 hex chars) so the deployed app passes production preflight. Taken from the environment when provided; otherwise a throwaway secret is generated per run — the VM is destroyed on exit, so it never needs a persistent value.The lifecycle is a self-contained shell script (
scripts/deploy-real-vps-validate.sh) that mirrors the container harness's curl/ssh assertions while driving the realautumnbinary; the Hetzner-specific provisioning is isolated in the workflow so another provider can be swapped in without touching the script. The in-container half of the pam_systemd socket check runs as part of the standardcargo test -p autumn-cli --test deploy_e2e -- --ignoredDocker sweep (no separate feature needed).
Step 1 — Create the project
autumn new myapp
cd myapp
This scaffolds a working Autumn application with a dev-oriented Dockerfile.
Step 2 — Generate production deployment files
autumn release init --force
--force is required because autumn new already wrote a basic Dockerfile
and .dockerignore. The --force flag replaces them with the production-grade
versions.
The command emits three files at the project root:
| File | Purpose |
|---|---|
Dockerfile | Multi-stage image: cargo-chef dep cache → release binary → debian-slim runtime |
.dockerignore | Keeps target/, .git/, node_modules/, dist/ out of the build context |
autumn.production.toml.example | Production config template with placeholder values — no real secrets |
What changed in the Dockerfile? The production Dockerfile adds cargo-chef dependency caching (so rebuilds only recompile what changed), installs
libpq,tini, andca-certificatesin the slim runtime, copies compiled Tailwind assets fromstatic/, leaves migrations to an explicit primary-role job, and wires the/healthendpoint as the containerHEALTHCHECK.
Step 3 — Build the image
docker build -t myapp .
The first build downloads Rust crates and the Tailwind binary; subsequent builds are fast because cargo-chef caches the dependency layer separately from your application code.
Expected final output:
[...]
=> CACHED [runtime 2/7] RUN apt-get update ...
=> [runtime 7/7] COPY --from=builder /app/autumn.production.toml.example /app/autumn.toml
=> exporting to image
Successfully built <sha>
Successfully tagged myapp:latest
Step 4 — Migrate, Then Run the Container
Provide your primary/write Postgres connection string as
AUTUMN_DATABASE__PRIMARY_URL. Run migrations once against that primary role
before starting web replicas:
AUTUMN_DATABASE__PRIMARY_URL="postgres://user:pass@host:5432/myapp_prod" autumn migrate
Then start the web container:
docker run --rm \
-p 3000:3000 \
-e AUTUMN_DATABASE__PRIMARY_URL="postgres://user:pass@host:5432/myapp_prod" \
myapp
You should see something like:
INFO autumn: Listening addr=0.0.0.0:3000
Visit http://localhost:3000/health — a healthy response looks like:
{ "status": "ok", "version": "0.6.0" }
Migration failure stops the rollout. If the primary URL is wrong or the database is unreachable,
autumn migrateexits non-zero and you do not roll the web tier. Fix the connection string and rerun the one-shot job.
How the production image works
rust:1.88.0-bookworm (chef stage)
└─ cargo chef prepare # snapshot dependency graph
└─ cargo chef cook # build all dependencies (cached layer)
└─ autumn build --embed # fingerprint + embed assets (embed-assets feature)
└─ debian:bookworm-slim (runtime stage)
libpq5, tini, ca-certificates, curl
/usr/local/bin/myapp ← your binary (assets + locales embedded)
/app/migrations/ ← SQL migration files (one-shot migrate job)
/app/autumn.toml ← production config (host=0.0.0.0)
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/myapp"]
Projects without the
embed-assetsfeature instead build withcargo build --releaseand stage/app/static/— see Single-binary deploys.
Key design decisions:
- cargo-chef separates the dependency build layer from your code. Changing a handler reuses cached dependencies; only your crate recompiles.
- tini is the PID 1 init process. It reaps zombie processes and forwards signals (SIGTERM, SIGINT) so the server shuts down gracefully.
- Explicit migration ownership -- migrations run once through
AUTUMN_DATABASE__PRIMARY_URL=... autumn migratebefore web replicas roll. The web image starts only the server, so replicas do not race schema changes. autumn.production.toml.exampleis copied as/app/autumn.tomlso the binary binds to0.0.0.0(all interfaces) instead of the dev default127.0.0.1. Override any value at runtime viaAUTUMN_*environment variables (see the config reference).
Single-binary deploys (embedded assets)
Autumn's design pillar is single binary deployment: copy one file, run it, no
sidecar directories. The generated release image delivers on this by embedding
the app's static/ tree (CSS/JS/fonts and the fingerprint manifest) and its
i18n i18n/ locale bundles into the binary at compile time — the same way Diesel
migrations are embedded with embed_migrations!. With assets embedded:
scp ./myapp host && ./myappserves styled, localized pages from an empty directory — every referenced asset returns200, nostatic//i18n/to stage.asset_url()resolves against the embedded manifest (no disk read). The manifest and the files are baked from the same build, so fingerprint-vs-manifest drift is impossible.
How it works
Embedding is an opt-in, release-time concern (a Cargo feature). In development — or whenever the feature is off — the app serves from disk so CSS/JS/translation hot-reload is unaffected.
Generated apps are wired for it out of the box:
// src/main.rs (generated)
#[cfg(feature = "embed-assets")]
static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();
#[autumn_web::main]
async fn main() {
let app = autumn_web::app().routes(routes![/* … */]).migrations(MIGRATIONS);
#[cfg(feature = "embed-assets")]
let app = app.embedded_static(&EMBEDDED_STATIC);
app.run().await;
}
# Cargo.toml (generated)
[features]
embed-assets = ["autumn-web/embed-assets"]
i18n apps additionally embed locales via embed_locales!() /
.embedded_locales(&EMBEDDED_LOCALES).
Building
autumn build --embed
This compiles your build scripts (e.g. Tailwind), fingerprints static/, then
recompiles with the embed-assets feature so the manifest and assets are baked in.
autumn release init detects the embed-assets feature in your Cargo.toml: when
present it emits a release Dockerfile that runs autumn build --embed and does
not COPY static/i18n into the runtime image (only migrations/ is staged, for
the one-shot autumn migrate job). Projects without the feature get the disk-based
build (cargo build --release + COPY static) so their Docker builds keep working.
Adding embedding to an existing app: add the
[features]block above, wire.embedded_static()(and.embedded_locales()if you use i18n) behind#[cfg(feature = "embed-assets")], then re-runautumn release init --forceand build withautumn build --embed.
Customising the production config
autumn.production.toml.example is the starting point for production config.
It is already used by the container (copied as /app/autumn.toml at build time).
To change log format, pool size, or health path, edit
autumn.production.toml.example before building:
# autumn.production.toml.example (excerpt)
[server]
host = "0.0.0.0"
port = 3000
[log]
level = "info"
format = "Json" # structured JSON for log aggregators
[database]
primary_url = "postgres://user:CHANGE_ME@localhost:5432/myapp_prod"
# replica_url = "postgres://user:CHANGE_ME@replica:5432/myapp_prod"
pool_size = 10
replica_fallback = "fail_readiness"
auto_migrate_in_production = false
Sensitive values (database password, SMTP credentials) should never be in this file. Pass them as environment variables at runtime:
-e AUTUMN_DATABASE__PRIMARY_URL="postgres://user:realpass@host:5432/myapp_prod"
-e AUTUMN_LOG__LEVEL=debug
AUTUMN_* environment variables override autumn.toml at the highest
priority layer — see the
config reference.
Trusted hosts (Host-header allow-list)
Autumn supports a host allow-list to prevent host-header rebinding and cache-poisoning style attacks.
[security.trusted_hosts]
hosts = ["app.example.com", ".example.com"]
app.example.commatches exactly that hostname..example.commatches bothexample.comand any subdomain likeapi.example.com.hosts = ["*"]disables host filtering (escape hatch; not recommended for production).
In prod/production profile, startup fails when security.trusted_hosts.hosts is empty.
Health/probe routes (/actuator/health, /live, /ready, /startup) intentionally bypass host checks so orchestration probes remain reliable.
Runnable repro
# Expected: 400 + application/problem+json
curl -i http://localhost:3000/ -H 'Host: evil.example'
# Expected: normal route response
curl -i http://localhost:3000/ -H 'Host: app.example.com'
Deploy to fly.io
Scaffold a fly.toml alongside the production Dockerfile:
autumn release init --force --target fly
The generated fly.toml includes four first-class integrations:
| Feature | What it does |
|---|---|
/live + /ready checks | Fly uses /live to decide machine restarts; /ready to gate traffic routing. Autumn flips /ready to 503 at drain start so Fly deregisters before the listener closes. |
kill_timeout = 45 | Fly waits 45 s after SIGTERM before SIGKILL — prestop_grace_secs (5) + shutdown_timeout_secs (30) + 10 s buffer for the process to log and exit cleanly. Value is an integer (seconds); Fly does not accept a string like "45s". |
[metrics] → /actuator/prometheus | Fly scrapes Autumn's Prometheus text endpoint and surfaces it in the dashboard. No extra agent needed. Controlled by actuator.prometheus (default on) and independent of actuator.sensitive — see Prometheus metrics for platform scraping. |
[deploy] release_command (opt-in) | When uncommented, migrations run in a one-shot machine before new app machines start; a failed migration aborts the deploy before any traffic-serving machine is replaced. |
Deploy:
fly launch --no-deploy # creates the app on fly.io
fly secrets set AUTUMN_DATABASE__PRIMARY_URL="postgres://user:pass@host:5432/myapp_prod"
fly secrets set AUTUMN_SECURITY__SIGNING_SECRET="$(openssl rand -hex 32)"
fly deploy
Using a database? Uncomment the release_command line in fly.toml before
the first fly deploy:
[deploy]
release_command = "autumn migrate"
With it enabled, Fly runs autumn migrate in a temporary machine before new app
machines start. A failed migration aborts the deploy before any traffic-serving
machine is replaced — keeping the old version live. The line is commented out by
default because autumn migrate exits non-zero when no database URL is set,
which would fail the first deploy of a database-free app.
If you add a read replica, set AUTUMN_DATABASE__REPLICA_URL as a secret and
Autumn gates /ready until the replica has replayed the latest migration.
Prometheus metrics for platform scraping
Autumn exposes a Prometheus text endpoint at /actuator/prometheus. It is
controlled by actuator.prometheus (default true) and is independent of
actuator.sensitive. That separation is the whole point: a production app can
let Fly.io (or any scraper) collect metrics while keeping actuator.sensitive = false, so /actuator/env, /actuator/configprops, /actuator/loggers,
/actuator/tasks, /actuator/jobs, and the actuator task UI stay off the
public surface.
# autumn.toml — metrics on, sensitive surfaces off (the safe production shape)
[actuator]
sensitive = false # env/configprops/loggers/tasks/jobs NOT mounted
prometheus = true # /actuator/prometheus still scrapeable
To remove the scrape endpoint entirely (it then returns 404), set
prometheus = false — either in autumn.toml or via the environment override
AUTUMN_ACTUATOR__PROMETHEUS=false (the whole [actuator] section follows the
standard AUTUMN_SECTION__FIELD convention). Regression tests assert both
directions — the endpoint is present under the non-sensitive config and absent
when export is disabled.
The generated fly.toml wires Fly's [metrics] block to this endpoint:
[metrics]
port = 3000
path = "/actuator/prometheus"
Keeping metrics off the public HTTP service
/actuator/prometheus carries operational counters, not secrets, but you may
still want it unreachable from public traffic. The Fly-native way is to scrape a
separate, non-public port rather than the port behind [http_service].
Bind a second internal listener and point [metrics] at it:
[metrics]
port = 9091 # internal-only; no [http_service] on it
path = "/actuator/prometheus"
Fly scrapes [metrics] over the private 6PN network, so a port that has no
[http_service] / force_https mapping is reachable by the Fly metrics
collector but not by the public internet. Front the public app on its own port
and reserve the metrics port for scraping. (If you only run a single listener,
gate access at the edge or accept that the counters are publicly readable —
they contain no credentials.)
OTLP tracing and Prometheus are separate telemetry paths
Enabling OTLP tracing (telemetry.enabled = true + telemetry.otlp_endpoint)
initializes span export to an OTLP collector. It does not feed
OpenTelemetry metrics into /actuator/prometheus. The Prometheus endpoint is
backed by Autumn's in-process request MetricsCollector snapshot plus any
registered MetricsSource families — it is a distinct
pipeline from the OTLP trace exporter. Treat them as two independent channels:
- Tracing → OTLP collector (Jaeger, Tempo, Honeycomb, …) via the OTLP path.
- Metrics →
/actuator/prometheusscraped by Fly[metrics]or Prometheus.
Turning on one does not populate the other. Bridging OTLP metrics into the Prometheus scrape would require an explicit metrics exporter/bridge, which Autumn does not add implicitly.
Run locally with Docker Compose (app + Postgres)
Scaffold a docker-compose.yml with an app service, a one-shot migration job,
and a managed Postgres:
autumn release init --force --target docker-compose
Start both services:
export AUTUMN_SECURITY__SIGNING_SECRET="$(openssl rand -hex 32)"
docker compose up --build
The docker-compose.yml sets AUTUMN_DATABASE__PRIMARY_URL pointing at the
db service, waits for Postgres to pass its healthcheck, runs autumn migrate
once, passes AUTUMN_SECURITY__SIGNING_SECRET into the app service, and starts
the app only after that job exits successfully. No manual Postgres setup is
needed.
To reset the database:
docker compose down -v # removes the postgres_data volume
docker compose up --build
Overwriting specific files
By default autumn release init refuses to overwrite existing files:
Error: 'Dockerfile' already exists — run with --force to overwrite
Use --force to regenerate everything, or delete individual files first if you
only want to regenerate a subset.
Signing secret (required before production boot)
Before the server will bind in the prod profile, you must set a stable signing
secret. It protects sessions, CSRF tokens, and signed storage URLs:
# Generate once, store securely (e.g. Fly secrets, AWS Secrets Manager, …)
export AUTUMN_SECURITY__SIGNING_SECRET="$(openssl rand -hex 32)"
Smoke-gate check — the app must refuse to boot without the secret:
docker run --rm \
-e AUTUMN_ENV=prod \
-e AUTUMN_DATABASE__PRIMARY_URL=... \
myapp 2>&1 | grep -i "signing secret"
# Expected: "Invalid signing secret configuration: signing secret is required in production"
And must start successfully with a valid secret:
docker run --rm -p 3000:3000 \
-e AUTUMN_ENV=prod \
-e AUTUMN_DATABASE__PRIMARY_URL=... \
-e AUTUMN_SECURITY__SIGNING_SECRET="$AUTUMN_SECURITY__SIGNING_SECRET" \
myapp
See docs/guide/signing-secrets.md for rotation instructions and the full multi-replica setup guide.
Multi-replica setup
To run multiple replicas behind a load balancer, every replica must use the same signing secret and the same Redis session backend. A session established on replica A must be readable by replica B.
SECRET=$(openssl rand -hex 32)
# Replica 1
docker run --rm -p 3000:3000 \
-e AUTUMN_ENV=prod \
-e AUTUMN_DATABASE__PRIMARY_URL=postgres://... \
-e AUTUMN_SECURITY__SIGNING_SECRET="$SECRET" \
-e AUTUMN_SESSION__BACKEND=redis \
-e AUTUMN_SESSION__REDIS__URL=redis://redis:6379 \
myapp &
# Replica 2 — identical secret, primary URL, and Redis URL
docker run --rm -p 3001:3000 \
-e AUTUMN_ENV=prod \
-e AUTUMN_DATABASE__PRIMARY_URL=postgres://... \
-e AUTUMN_SECURITY__SIGNING_SECRET="$SECRET" \
-e AUTUMN_SESSION__BACKEND=redis \
-e AUTUMN_SESSION__REDIS__URL=redis://redis:6379 \
myapp &
With this setup:
- A user who logs in via replica 1 is authenticated on replica 2 without re-logging in (sessions live in Redis, signed with the shared secret).
- Signed blob URLs generated on replica 1 are served correctly by replica 2 (same HMAC key).
- CSRF tokens validate regardless of which replica handles the form submission.
Global rate limiting
By default the rate limiter keeps per-IP token buckets in memory per replica.
A 3-replica deployment therefore permits up to 3× the configured rate — enough
to undermine the protection intended by your requests_per_second setting.
To enforce the budget globally, point the rate limiter at the same Redis instance as your session store:
[security.rate_limit]
enabled = true
requests_per_second = 10.0
burst = 20
backend = "redis"
on_backend_failure = "fail_open" # or "fail_closed"
[security.rate_limit.redis]
url = "redis://redis:6379"
key_prefix = "myapp:rate_limit"
Or with environment variables alongside the session and cache settings:
AUTUMN_SECURITY__RATE_LIMIT__BACKEND=redis
AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL=redis://redis:6379
| Setting | Effect |
|---|---|
backend = "memory" | Default. Each replica enforces the limit independently. |
backend = "redis" | Global enforcement via atomic Lua token-bucket in Redis. |
on_backend_failure = "fail_open" | Requests pass through when Redis is unreachable (default). |
on_backend_failure = "fail_closed" | Requests receive 429 until Redis recovers. |
One tracing::warn! is emitted when Redis becomes unavailable and again when it
recovers, so log volume stays low during outages.
Continuous integration
autumn new writes .github/workflows/ci.yml into every generated project.
The workflow runs automatically on every branch push and pull request, so CI
fires on your first push no matter what the default branch is named:
| Step | Command |
|---|---|
| Format check | cargo fmt --all -- --check |
| Lint | cargo clippy --all-targets -- -D warnings |
| Build | cargo build |
| Test | cargo test |
The Rust toolchain is pinned to the project MSRV (1.88.0+) via
dtolnay/rust-toolchain@<msrv> so local and CI toolchains can't drift.
A Postgres 16 service container is provisioned and DATABASE_URL is wired in
so DB-dependent tests can opt in. Tests marked #[ignore] are skipped in the
default cargo test run; pass -- --ignored to include them.
Extending the CI workflow
Tailwind CSS: install the Tailwind CLI (autumn setup --tailwind) and add a
step before cargo build to run it. The generated build.rs will auto-detect
it on PATH or at target/autumn/tailwindcss.
Coverage: install cargo-llvm-cov (taiki-e/install-action@cargo-llvm-cov)
and upload the LCOV report to Codecov. Coverage gating is out of scope for the
generated scaffold but straightforward to add.
Audit: cargo install cargo-audit --locked then cargo audit as a separate
step. Recommended before production deploys.
Next steps
Once the container is running:
- Monitor:
autumn monitor --url http://your-host:3000for a live TUI dashboard of metrics, logs, and routes. - Scale: add
min_machines_running = 1infly.tomlto keep a warm instance; usepool_sizeinautumn.production.toml.exampleto tune database concurrency. - Observe: uncomment the
[telemetry]block inautumn.production.toml.exampleand point it at an OTLP collector for distributed tracing. - Harden: run
autumn doctor --strictin CI before building the image to catch config issues before they reach production.
For a full cloud-native deployment (Kubernetes readiness probes, structured logging, OTLP tracing), see the Cloud-Native Guide.