Back to Blog
Tutorial7 min read2026-07-10

How to Deploy Actix Web (Rust) with PandaStack

Ship an Actix Web app to production: release builds, a cached Dockerfile, sqlx migrations against managed Postgres, and going live with a git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Rust deployments are a strange inversion of what most web developers are used to. The build is the expensive part — a cold cargo build --release on a mid-size Actix project can take several minutes — but the artifact you get out is a single static-ish binary that starts in milliseconds and idles at a few megabytes of RAM. Get the build pipeline right once and the runtime side is almost boring. Here's the whole path, end to end.

The two lines that break most Rust deploys

Nearly every Actix tutorial starts with this:

HttpServer::new(|| App::new().service(hello))
    .bind(("127.0.0.1", 8080))?
    .run()
    .await

That works on your laptop and fails silently in a container. 127.0.0.1 binds the loopback interface only — the platform's router can reach the container's network interface, but nothing is listening on it, so every request times out. The second problem is the hardcoded port: most platforms tell your app which port to use via the PORT environment variable.

The production version reads both from the environment:

use actix_web::{get, App, HttpServer, Responder};

#[get("/healthz")]
async fn healthz() -> impl Responder {
    "ok"
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let port: u16 = std::env::var("PORT")
        .ok()
        .and_then(|p| p.parse().ok())
        .unwrap_or(8080);

    HttpServer::new(|| App::new().service(healthz))
        .bind(("0.0.0.0", port))?
        .run()
        .await
}

One more Actix-specific note: HttpServer spawns one worker per logical CPU by default. In a container with a small CPU allocation that default is fine, but if you want to pin it, .workers(2) on the builder does it. Don't set it higher than your CPU limit — you'll just add context-switching overhead.

Building for production

Locally, the release build is one command:

cargo build --release
./target/release/myapp

The binary lands at target/release/, where comes from the [package] section of Cargo.toml. That's the only thing you need at runtime — no node_modules, no interpreter, no framework runtime.

A Dockerfile that doesn't rebuild the world

The naive Rust Dockerfile recompiles every dependency on every code change, because COPY . . invalidates the layer cache before cargo build runs. [cargo-chef](https://github.com/LukeMathWalker/cargo-chef) fixes this by building dependencies in a separate cached layer, so a routine code change only recompiles your crate:

FROM rust:1-slim AS chef
RUN cargo install cargo-chef
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies only — this layer is cached until Cargo.toml changes
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
USER 1000
EXPOSE 8080
CMD ["myapp"]

Two details worth calling out:

  • ca-certificates in the runtime image matters the moment your app makes an outbound HTTPS call (Stripe, S3, any API). Without it you get opaque TLS errors at runtime that look nothing like a missing-package problem.
  • If a dependency pulls in openssl-sys, the slim builder needs pkg-config and libssl-dev. You can dodge that whole class of pain by preferring rustls feature flags — sqlx, reqwest, and most of the ecosystem support it.

On PandaStack this Dockerfile is all you need: connect the repo as a container app and the image is built with rootless BuildKit in an ephemeral Kubernetes Job pod, pushed to a registry, and deployed — no Docker daemon on any host, and you can watch the full cargo build output in the live build logs (useful, because Rust build errors are long).

Postgres with sqlx

sqlx is the most common database layer in Actix apps, and it has a convention that fits managed platforms perfectly: it reads DATABASE_URL natively. When you provision a managed PostgreSQL instance on PandaStack and attach it to your app, DATABASE_URL is injected into the container automatically — so the pool setup is just:

use sqlx::postgres::PgPoolOptions;

let pool = PgPoolOptions::new()
    .max_connections(5)
    .connect(&std::env::var("DATABASE_URL").expect("DATABASE_URL not set"))
    .await
    .expect("failed to connect to Postgres");

Keep max_connections modest. A Rust binary handles a lot of traffic on 5–10 connections, and managed databases enforce connection limits per plan — burning them on an oversized pool buys you nothing.

Hand the pool to your handlers via web::Data:

HttpServer::new(move || {
    App::new()
        .app_data(web::Data::new(pool.clone()))
        .service(healthz)
})

Migrations

Install the CLI once, then manage migrations as versioned SQL files:

cargo install sqlx-cli --no-default-features --features rustls,postgres
sqlx migrate add create_users
sqlx migrate run   # applies pending migrations from ./migrations

For running them in production you have two decent options. The explicit one is sqlx migrate run as a separate release step before new code takes traffic. The pragmatic one — common in Rust services — is embedding them in the binary so they apply at boot:

sqlx::migrate!("./migrations").run(&pool).await?;

The embedded approach means the binary is fully self-contained: same artifact, any environment, schema always current. sqlx takes a database-level lock while migrating, so two instances starting at once won't trample each other.

The compile-time checking gotcha

If you use sqlx's query! macros, they validate your SQL against a live database *at compile time* — which means your Docker build would need a database connection. It doesn't have one, and shouldn't. The fix is offline mode:

cargo sqlx prepare   # writes query metadata into .sqlx/

Commit the .sqlx directory, and set SQLX_OFFLINE=true in the build (an ENV SQLX_OFFLINE=true line in the builder stage works). Forgetting this is probably the single most common failed-first-build for sqlx projects — the error mentions DATABASE_URL must be set during compilation, which is confusing until you know what the macros are doing.

Environment variables

Beyond PORT and the injected DATABASE_URL, the one you always want is:

RUST_LOG=info,myapp=debug

Actix's logger middleware and anything built on tracing/env_logger read it. Set it in your app's environment variables in the dashboard — logs stream live from the running container, so RUST_LOG=debug plus the log viewer is a workable first debugging loop before you reach for anything heavier.

Going live

The actual deploy is the anticlimax:

  1. 1Provision a managed PostgreSQL (14.x or 16.x) and attach it to the app — DATABASE_URL appears in the container environment without copying credentials anywhere.
  2. 2Connect the Git repo as a container app with the Dockerfile above.
  3. 3git push. The build runs, logs stream, and the app goes live on a subdomain — add a custom domain and SSL is handled automatically.

Every push after that is a deploy, with rollbacks and deployment history if a release goes sideways.

One honest note on the free tier: apps scale to zero when idle and wake on the next request. For most runtimes cold starts are the pain point of scale-to-zero — but a release-built Actix binary starts in milliseconds, so Rust is close to the best-case scenario for it. It's a genuinely good fit for side projects that see bursty traffic.

Quick checklist

  • Bind 0.0.0.0, read PORT from the environment
  • Use cargo-chef so code changes don't recompile every dependency
  • ca-certificates in the runtime image; prefer rustls features over OpenSSL
  • Commit .sqlx/ and set SQLX_OFFLINE=true if you use query! macros
  • Keep the connection pool small; the database's connection limit is per plan
  • Set RUST_LOG so you're not debugging blind

If you've got an Actix project sitting in a repo, it's a short afternoon to get it live on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also