A Rust (Axum) Service in a 12 MB Image: Fast at Runtime, Slow to Build, and How to Fix the Second Part
A Rust web service is the closest thing to a free lunch on a small container: a static binary of a few megabytes, single-digit megabytes of memory at idle, and no runtime to warm up. The price is paid at build time, where a cold cargo build --release can take longer than every other language in this series combined. This guide builds an Axum service the way a container host wants it, keeps the image around 10 MB, and spends most of its words on making the build bearable, because that is where Rust on a container actually hurts.
The service
// src/main.rs
use axum::{routing::get, Json, Router};
use serde_json::json;
use tokio::{net::TcpListener, signal};
async fn health() -> &'static str { "ok" }
async fn time() -> Json<serde_json::Value> {
Json(json!({ "now": chrono::Utc::now().to_rfc3339() }))
}
#[tokio::main]
async fn main() {
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".into());
let app = Router::new().route("/health", get(health)).route("/api/time", get(time));
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap();
println!("listening on :{port}");
axum::serve(listener, app)
.with_graceful_shutdown(async { signal::ctrl_c().await.ok(); })
.await
.unwrap();
}PORT from the environment, bound on all interfaces, and a graceful shutdown hook: when a free container goes to sleep after 15 quiet minutes the platform sends a stop signal, and a server that finishes in-flight requests exits with code 0 instead of being killed. For a real service, handle SIGTERM as well as Ctrl-C with tokio::signal::unix; the pattern is the same.
The Dockerfile, and why the build stage is the whole story
FROM rust:1-alpine AS chef
RUN apk add --no-cache musl-dev && cargo install cargo-chef
WORKDIR /src
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS build
COPY --from=planner /src/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release && strip target/release/myservice
FROM alpine:3
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app
USER app
COPY --from=build /src/target/release/myservice /myservice
EXPOSE 8080
ENTRYPOINT ["/myservice"]rust:1-alpinetracks the current stable toolchain and builds against musl, so the binary is static and runs on plain Alpine (orscratch, if you do not need a shell or CA certificates).cargo-chefis the point of the three stages: it compiles your dependencies into a layer that only changes whenCargo.tomlorCargo.lockchanges. Without it, every push recompiles tokio, hyper and serde from scratch; with it, a code-only change rebuilds your crate alone.- The runtime image is Alpine plus certificates plus the binary. The service above strips to about 4 MB; the image is about 12 MB.
SnapDeploy does not generate a Dockerfile for Rust (its generator covers Node.js, Python, Java, Go, PHP, Ruby and static sites), so this file is required, not optional. The platform reads the port from EXPOSE and builds the image for ARM; the official rust image is multi-architecture, so nothing changes.
Build time, honestly
A cold release build of a small Axum service with tokio, serde and chrono takes several minutes on a build machine; a large dependency tree takes longer. That matters on a free tier in two ways. First, the daily deploy allowance (5 per rolling 12 hours) is spent per attempt, so a build that fails at the end is an expensive way to find a typo: run cargo build --release locally, or at least cargo check, before you push. Second, the layer caching between builds on a hosted build service is limited, so the recipe layer from cargo-chef is what keeps warm builds short. Keep Cargo.lock committed; without it the recipe changes on every resolution.
What it uses at runtime
Measured on the service above: about 3 MB resident at idle, under 15 MB while serving a few hundred requests a second, and start-up in milliseconds. In a 512 MB container that leaves room for large in-memory caches or an embedded SQLite for read-mostly data. On a free container the wake after sleeping is about a minute, and essentially all of it is the platform starting the task; the Rust process is ready as soon as it is scheduled.
A database without a runtime
SQLx with the runtime-tokio and tls-rustls features keeps the binary static (no OpenSSL to link). Read DATABASE_URL from the environment, which a SnapDeploy PostgreSQL add-on injects, and cap the pool: a Mini allows 50 connections and one small service needs five.
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(3))
.connect(&std::env::var("DATABASE_URL")?)
.await?;SQLx's compile-time query checking needs a database at build time; on a hosted build use the offline mode (cargo sqlx prepare committed as .sqlx/) so the build machine does not need one.
Deploying
- Commit the Dockerfile,
Cargo.lock, and a.dockerignorewithtarget/and.git. - Push. Containers → Deploy from GitHub → pick the repository. Port 8080.
- Deploy and go make coffee: the first build is the long one. Then open
/healthand/api/timeon the container URL. - Push a one-line change and time the second build to confirm the cargo-chef layer is being reused.
Where free stops
- Sleep after 15 minutes without traffic; about a minute to wake; 100 container-hours a month across four containers.
- Custom domains and no sleeping: Always-On, $12/month per container. A Rust service almost never needs the larger sizes.
- No persistent disk: an embedded SQLite is for caches and read-only data; anything you must keep goes to a PostgreSQL add-on ($29/month, or $1 for a 12-hour trial) or an external database.
Logging that the dashboard can show
Write logs to stdout, one line per event, and they appear in the container's runtime log. With tracing and tracing-subscriber, initialise the subscriber with EnvFilter::from_default_env() so RUST_LOG=info in the container settings controls the level without a rebuild, and add tower_http::trace::TraceLayer to the router for one line per request with status and latency. Avoid file appenders: the filesystem is ephemeral and nobody reads a file inside a container.
Free tier: 4 containers, 512 MB and 0.25 vCPU each, 100 container-hours a month, 10 deploys a day, no card. Details on the free tier docs page; the Docker hosting page covers deploying a prebuilt image instead of a repository.