Axum has become the default choice for Rust web services — built by the Tokio team, it's fast, ergonomic, and composes beautifully with the Tower middleware ecosystem. Rust's reward for its compile-time strictness is a deployment story that's almost boring: a single static-ish binary, tiny memory footprint, and no runtime to install. This guide covers building a lean container image and deploying an Axum API with a managed PostgreSQL database.
Why Rust is a joy to deploy
A compiled Rust binary has no interpreter, no garbage collector, and minimal runtime dependencies. The result is a container that's small, starts fast, and sips memory — which pairs especially well with scale-to-zero environments where cold-start cost matters. The only real deployment trick is producing a small image, since the Rust build toolchain is large. Multi-stage Docker builds solve that.
Step 1: A minimal Axum app
use axum::{routing::get, Router, Json};
use serde::Serialize;
use std::net::SocketAddr;
#[derive(Serialize)]
struct Health { status: &'static str }
async fn health() -> Json<Health> {
Json(Health { status: "ok" })
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(health));
// bind to the platform-provided PORT, default 3000
let port: u16 = std::env::var("PORT")
.ok().and_then(|p| p.parse().ok()).unwrap_or(3000);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}The two production essentials: bind to 0.0.0.0 (not localhost) and read PORT from the environment.
Step 2: The multi-stage Dockerfile
This is where image size is won. Build in a full Rust image, then copy just the binary into a tiny runtime image:
# ---- build stage ----
FROM rust:1.82 AS builder
WORKDIR /app
# cache dependencies first
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY . .
RUN cargo build --release
# ---- runtime stage ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/my-api /usr/local/bin/my-api
EXPOSE 3000
CMD ["my-api"]The dummy-main.rs trick caches your dependency compilation as a separate layer, so editing your source doesn't recompile every crate. That alone can turn a 10-minute rebuild into seconds.
| Image strategy | Approx size | Notes |
|---|---|---|
Single-stage rust:1.82 | ~1.5 GB+ | Ships the whole toolchain |
Multi-stage on debian:slim | ~80-120 MB | Recommended baseline |
Static musl on scratch/distroless | ~10-20 MB | Smallest, needs musl target |
For the absolute smallest image, build with the x86_64-unknown-linux-musl target and copy into scratch or distroless — but debian:slim is the pragmatic default that avoids musl/OpenSSL headaches.
Step 3: Add a database
Most APIs need persistence. sqlx is a great async, compile-time-checked choice. Read the connection string from the environment:
use sqlx::postgres::PgPoolOptions;
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&std::env::var("DATABASE_URL")?)
.await?;Using DATABASE_URL means the platform can inject it for you.
Step 4: Deploy on PandaStack
- 1Provision a managed PostgreSQL database (16.x).
- 2Create a container app from your repo. PandaStack builds your Dockerfile with rootless BuildKit — watch the live build logs as Cargo compiles.
- 3Link the database —
DATABASE_URLis auto-injected, exactly whatsqlxreads. No manual connection-string handling. - 4Attach a custom domain with automatic SSL.
That's it. Rust's small binary means fast deploys and low memory, so even the smaller compute tiers go a long way.
Step 5: Production polish
- Migrations — run
sqlx migrate runas a release/pre-deploy step, or usesqlx::migrate!()at startup for embedded migrations. - Tracing — add
tower-http'sTraceLayerand thetracingcrate for structured logs that show up in your platform's live logs. - Graceful shutdown — use
axum::serve(...).with_graceful_shutdown(...)so in-flight requests finish on redeploy. - Health checks — keep the
/healthendpoint cheap; platforms use it for readiness.
Build-time considerations
Rust's strength (thorough compilation) means longer build times than interpreted languages, especially on first build. Mitigations:
- The dependency-caching layer trick above.
- Keep your dependency tree lean; every crate adds compile time.
- Use
cargo build --releaseonly for deploys; the optimized build is slower but produces the fast binary you want in production.
Honest caveats
Rust deployment is genuinely pleasant, but the build is the slow part — large dependency trees and release optimization mean your CI/deploy step takes longer than a Node or Python app. The caching strategy helps a lot, but plan for it. The runtime, however, is the payoff: tiny, fast, and memory-frugal. For latency-sensitive or high-concurrency APIs, that trade is well worth it.
Wrapping up
Deploying an Axum API is mostly about producing a lean image via a multi-stage build and binding correctly to PORT and 0.0.0.0. Add a managed Postgres with an auto-injected DATABASE_URL, layer in tracing and graceful shutdown, and you have a production Rust service that's fast and cheap to run.
PandaStack builds your Dockerfile, auto-wires the database, and serves it with automatic SSL — and Rust's small footprint is a great fit for the free tier. Deploy your Axum API at https://dashboard.pandastack.io.
References
- Axum documentation: https://docs.rs/axum/latest/axum/
- Tokio project: https://tokio.rs/
- sqlx (async, compile-checked SQL): https://github.com/launchbadge/sqlx
- Multi-stage Docker builds: https://docs.docker.com/build/building/multi-stage/
- The Cargo book (release profiles): https://doc.rust-lang.org/cargo/reference/profiles.html