Deploying a Rust application consists of compiling a production binary and running it with the right configuration.
That sentence sounds simple, and in part it is. Rust generates native executables, so we don’t need to drag along half a runtime behind us. But it’s worth taking care of the build, environment variables, database, and Docker image.
Compiling in Release Mode
During development we use:
cargo runFor production we use:
cargo build --releaseThe binary ends up in:
target/release/project_namerelease mode activates optimizations. It takes longer to compile, but the executable is usually much faster.
Configuration via Environment Variables
A deployed application should not have hardcoded values like username, password, host, or port.
The usual approach is to read configuration from environment variables:
use std::env;
fn port() -> u16 {
env::var("PORT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(3000)
}And for the database:
let database_url = std::env::var("DATABASE_URL")
.expect("Missing DATABASE_URL");This allows us to use the same image in local, staging, and production by just changing the configuration.
Listening on 0.0.0.0
Locally we usually listen on 127.0.0.1 because we only want to access from our own machine.
Inside Docker we need to listen on 0.0.0.0 so that the process accepts connections from outside the container:
let addr = format!("0.0.0.0:{}", port());
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap();
axum::serve(listener, app).await.unwrap();This detail seems small, but it’s a classic one. If the container starts and you can’t access it from outside, first check if the app is listening on 127.0.0.1.
Multi-stage Docker Build
We don’t want to bring the Rust compiler into production. We want to compile in one image and copy only the final binary to a smaller one.
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/my_api /app/my_api
ENV PORT=3000
EXPOSE 3000
CMD ["/app/my_api"]This is called a multi-stage build. The first stage has everything needed for compilation. The second stage only has what’s needed to run.
.dockerignore
It’s a good idea to avoid copying junk into the Docker context:
target
.git
.envWe don’t want to send target to Docker, as it can be very large. And we also don’t want to accidentally include secrets.
If we use SQLx’s query! macros in offline mode, the .sqlx folder must be included in the build context. The compiler needs it inside the builder stage when it doesn’t have access to the database.
Migrations
If we use SQLx, we need to decide when migrations should run.
We have several options:
- Run them manually before deployment.
- Run them in CI/CD.
- Run them when the application starts.
For small projects, running them on startup can be convenient:
sqlx::migrate!()
.run(&pool)
.await?;For larger projects, it’s usually better for migrations to be part of the deployment pipeline, giving more control.
If we use SQLite, the database file needs a persistent volume. Any data written only to the container layer can disappear when the container is replaced.
Logs
In containers, the standard practice is to write logs to stdout/stderr. That is, println! works to get started, but in a real application it’s better to use tracing.
cargo add tracing tracing-subscriberAnd at startup:
tracing_subscriber::fmt::init();This way logs integrate well with Docker, Kubernetes, systemd, or whatever platform we deploy to.