Back to Blog
Tutorial7 min read2026-07-11

How to Deploy Axum (Rust) with PandaStack

Take an Axum service to production: correct listener setup, a distroless Dockerfile, embedded sqlx migrations, and graceful shutdown that actually fires.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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_users

Keep 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:

  1. 1Provision a managed PostgreSQL (14.x or 16.x) on PandaStack and attach it to your app. DATABASE_URL is injected into the container automatically.
  2. 2Connect the Git repo as a container app. The image builds with rootless BuildKit in an ephemeral Kubernetes Job pod — the full cargo build output streams in the live build logs, which matters the first time a query! macro fails because you forgot to commit .sqlx/.
  3. 3Set RUST_LOG=info (and anything else your app reads) in the environment variables panel.
  4. 4git 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, read PORT — the number one silent failure
  • Pool via State, migrations embedded with sqlx::migrate!, pool size small
  • SQLX_OFFLINE=true + committed .sqlx/ if you use checked queries
  • Handle SIGTERM with with_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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also