Axum has quietly become the default way to write web services in Rust: it's a thin, well-designed layer over tokio and tower, the router is just a function from requests to handlers, and everything composes. But "thin layer" cuts both ways — Axum makes almost no deployment decisions for you. There's no config system, no default port convention, no built-in migration story. You assemble those pieces yourself. This is the assembly guide.
The listener: where deploys go wrong first
Since Axum 0.7, you bind a tokio TcpListener yourself and hand it to axum::serve. The production-correct version reads the port from the environment and binds all interfaces:
use axum::{routing::get, Router};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let app = Router::new().route("/healthz", get(|| async { "ok" }));
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
axum::serve(listener, app).await?;
Ok(())
}The failure mode to avoid: binding 127.0.0.1 (the doc-example default) inside a container. The app runs, health checks fail, and every request from the platform's router times out because nothing is listening on the container's actual network interface. If your freshly deployed Axum app "starts fine but never responds," check the bind address before anything else.
State, Postgres, and DATABASE_URL
The idiomatic way to share a database pool in Axum is State. sqlx reads the standard DATABASE_URL variable, which is exactly what PandaStack injects when you attach a managed PostgreSQL instance to your app — the pool wiring becomes environment-driven with zero credential copying:
use axum::extract::State;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
async fn count_users(State(state): State<AppState>) -> Result<String, axum::http::StatusCode> {
let (count,): (i64,) = sqlx::query_as("SELECT count(*) FROM users")
.fetch_one(&state.db)
.await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(count.to_string())
}And in main, before building the router:
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&std::env::var("DATABASE_URL")?)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let app = Router::new()
.route("/users/count", get(count_users))
.with_state(AppState { db: pool });That sqlx::migrate! line embeds your ./migrations directory into the binary at compile time and applies pending migrations at boot. For a service deployed as a single artifact this is the setup I'd recommend: no separate migration step to forget, and sqlx holds a database-level lock while migrating so concurrent instance startups don't collide. Create migrations with the CLI:
cargo install sqlx-cli --no-default-features --features rustls,postgres
sqlx migrate add create_usersKeep the pool small — five connections go a long way in Rust, and managed databases cap connections per plan. A hundred-connection pool on a free-tier database just exhausts the limit for no throughput gain.
If you use the compile-time-checked query! macros, remember they need database access *during compilation*. Run cargo sqlx prepare, commit the generated .sqlx/ directory, and set SQLX_OFFLINE=true in your Docker build so the image can compile without a database connection.
Graceful shutdown — not optional with rolling deploys
Kubernetes-style platforms stop your old pods by sending SIGTERM, waiting a grace period, then killing. If your Axum process ignores SIGTERM, in-flight requests during every deploy get severed mid-response. Axum has first-class support for this; you just have to wire it:
async fn shutdown_signal() {
let sigterm = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {},
_ = sigterm => {},
}
}
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;With this in place, a deploy (or a scale-down) lets active requests finish before the process exits. It's ten lines, and it's the difference between clean rollouts and a low, steady rate of 502s that only shows up when you ship.
While you're in main, add request logging via tower-http — Axum deliberately doesn't log anything by default:
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.layer(TraceLayer::new_for_http())
.with_state(state);Pair it with tracing_subscriber::fmt().init() and a RUST_LOG=info environment variable, and your platform log stream actually has something to show you.
The Dockerfile
A release-built Axum binary has almost no runtime requirements, which makes it a great candidate for a distroless final image — no shell, no package manager, minimal attack surface:
FROM rust:1-slim AS builder
WORKDIR /app
ENV SQLX_OFFLINE=true
# Cache dependencies: build with a dummy main first
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
&& cargo build --release && rm -rf src
COPY . .
RUN touch src/main.rs && cargo build --release
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/myapp /myapp
EXPOSE 3000
ENTRYPOINT ["/myapp"]The dummy-main trick keeps dependency compilation in a cached layer, so day-to-day pushes only rebuild your own crate — the difference between a multi-minute build and a fast one. (cargo-chef is the more robust version of the same idea if your workspace has multiple crates.)
distroless/cc includes glibc and CA certificates, which covers a stock Axum + sqlx-with-rustls binary. If you compile with OpenSSL-linked dependencies instead, switch the final image to debian:bookworm-slim and install ca-certificates — debugging missing shared libraries inside a distroless image is nobody's idea of fun, since there's no shell to poke around with.
Deploying
With the Dockerfile committed, the platform side is short:
- 1Provision a managed PostgreSQL (14.x or 16.x) on PandaStack and attach it to your app.
DATABASE_URLis injected into the container automatically. - 2Connect the Git repo as a container app. The image builds with rootless BuildKit in an ephemeral Kubernetes Job pod — the full
cargo buildoutput streams in the live build logs, which matters the first time aquery!macro fails because you forgot to commit.sqlx/. - 3Set
RUST_LOG=info(and anything else your app reads) in the environment variables panel. - 4
git push. Build, deploy, live URL. Custom domains get SSL automatically, and deployment history gives you one-click rollbacks.
On the free tier, idle apps scale to zero and cold-start on the next request. Axum is about the best possible tenant for that model — the binary starts in milliseconds, so the wake-up cost is dominated by scheduling, not your app. Free-tier apps also run on preemptible nodes, which is one more reason the graceful-shutdown handler above isn't optional: preemptions look exactly like a rolling deploy to your process.
The short version
- Bind
0.0.0.0, readPORT— the number one silent failure - Pool via
State, migrations embedded withsqlx::migrate!, pool size small SQLX_OFFLINE=true+ committed.sqlx/if you use checked queries- Handle
SIGTERMwithwith_graceful_shutdown— deploys and preemptions both send it - Distroless image for rustls builds; debian-slim + ca-certificates if OpenSSL is in the tree
Wire those five things and an Axum service is one of the lowest-maintenance production workloads you can run — push it to https://pandastack.io and see for yourself.