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()
.awaitThat 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/myappThe 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-certificatesin 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 needspkg-configandlibssl-dev. You can dodge that whole class of pain by preferringrustlsfeature 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 ./migrationsFor 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=debugActix'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:
- 1Provision a managed PostgreSQL (14.x or 16.x) and attach it to the app —
DATABASE_URLappears in the container environment without copying credentials anywhere. - 2Connect the Git repo as a container app with the Dockerfile above.
- 3
git 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, readPORTfrom the environment - Use cargo-chef so code changes don't recompile every dependency
ca-certificatesin the runtime image; preferrustlsfeatures over OpenSSL- Commit
.sqlx/and setSQLX_OFFLINE=trueif you usequery!macros - Keep the connection pool small; the database's connection limit is per plan
- Set
RUST_LOGso 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.