12
I'll be honest: I like order and organization as much as the next obsessive person. But sometimes I think we invent complexity just to justify... well, whatever.
The same goes for jargon.
Today, however, I won't complain about that. I find The Twelve-Factor methology to hit the sweet spot (though the jargon bothers me).
12-Factor Methology
It's a practical set of constraints for building services that deploy cleanly, scale predictably, and don't turn into works on my laptop folklore. The core ideas map extremely well to modern Rust services running in containers, Kubernetes, Nomad, systemd, or basically anything that can start a process and feed it environment variables.
This post walks through all 12 factors with a concrete Rust shape: a small HTTP API using axum, tokio, and sqlx.
A tiny service that:
- exposes HTTP on a port (
/healthz,/v1/echo) - connects to Postgres via
DATABASE_URL - logs to
stdout - supports graceful shutdown (
SIGTERM) - runs migrations as a one-off admin process
Factor I — Codebase: one codebase, many deploys
One service = one codebase tracked in version control. If you have multiple codebases for one app, you're already in distributed-system territory; treat each component as its own app.
Rust fit: a single repo can still contain multiple binaries (like server + migrate) via src/bin/* and shared modules via src/*. That's still one codebase.
Factor II — Dependencies: declare and isolate
A twelve-factor app declares dependencies explicitly and avoids assuming system-wide packages exist.
Rust fit:
Cargo.tomldeclares dependenciesCargo.lockpins versions for reproducible builds- for native dependencies: prefer pure-Rust stacks when reasonable (e.g.,
rustlsvs OpenSSL), or make system deps explicit in your build image.
Factor III — Config: store config in the environment
Configuration that varies between deploys (ports, DB URLs, API keys) should come from environment variables.
A practical Rust pattern: deserialize env into a typed Settings struct.
src/config.rs:
use Deserialize;
Local dev convenience: use a .env file locally, but treat it as developer tooling, not the deployment system.
Factor IV — Backing services: treat them as attached resources
Databases, caches, queues, and object storage are backing services and should be treated as swappable attached resources.
Rust fit:
- put
DATABASE_URLin env - connect via a pool
- don't bake prod DB hostnames into code
src/db.rs:
use ;
use Duration;
pub async
Factor V — Build, release, run: strictly separate
The methodology wants strict separation between build, release, and run.
Rust fit:
- Build: compile a binary (
cargo build --release) - Release: package that build artifact + attach config (env vars, secrets, release metadata)
- Run: execute the same artifact with the release’s environment
A simple container flow:
- build stage compiles
tiny-svc - runtime stage runs
./tiny-svcand receives env from orchestrator
Key idea: no SSH into prod and edit code. If you changed code, you made a new build.
Factor VI — Processes: stateless, share-nothing
Processes should be stateless and share-nothing; persistent state belongs in backing services. 12factor
Rust fit:
- don't store sessions on disk
- don't rely on local filesystem as durable storage
- treat local memory as cache only (and disposable)
If you need sessions, use Redis or the DB. If you need files, use object storage.
Factor VII — Port binding: export services via a port
The app should be self-contained and bind to a port to serve requests.
Rust fit (axum):
use ;
use SocketAddr;
use TcpListener;
pub async
Factor VIII — Concurrency: scale out via the process model
The factor emphasizes scaling out by running more processes.
Rust reality check: Rust async can handle high concurrency inside one process, but twelve-factor wants you to be able to scale horizontally anyway.
So you do both:
- use async I/O for efficient per-process concurrency
- scale with more replicas when needed (N processes behind a load balancer)
Factor IX — Disposability: fast startup, graceful shutdown
Processes should start quickly and shut down gracefully for resilience and rapid deploys.
Rust fit: handle SIGTERM/CTRL-C and allow in-flight requests to finish.
Tokio provides guidance for graceful shutdown patterns.
Axum includes a graceful shutdown example you can adapt.
src/main.rs:
use ;
use ;
use EnvFilter;
async
async
In production you'll also want SIGTERM handling on Unix; the axum example shows the pattern.
Factor X — Dev/prod parity: keep them similar
Minimize gaps between dev/staging/prod; avoid SQLite locally, Postgres in prod surprises.
Rust fit:
- run the same DB engine locally via Docker Compose
- keep the same migration mechanism
- keep the same environment variable names
Example docker-compose.yml idea:
postgres:16- tiny-svc with
DATABASE_URL=postgres://...
Factor XI — Logs: treat logs as event streams
A twelve-factor app should not manage log files, it writes its event stream to stdout and the environment routes/aggregates it.
Rust fit: tracing + tracing-subscriber with JSON to stdout.
tracing-subscriber's fmt subscriber formats events and logs them to stdout.
Good defaults:
- structured logs (JSON)
- log level from env (
RUST_LOG=info)
Factor XII — Admin processes: run one-off tasks as one-off processes
Migrations, data backfills, and maintenance tasks should run in the same environment (same code + config) as the app.
Two common Rust approaches:
sqlx-cli
sqlx migrate run compares the DB migration history with migrations/ and runs pending migrations.
Docs.rs
This is often perfect in CI/CD:
- run migrations as a job
- then deploy the app
A dedicated admin binary
src/bin/migrate.rs:
async
Run it as:
DATABASE_URL=...
(And yes, cargo run -- ... passes args to your binary if you need them.)
:tada: