Building Minimal and Secure Rust Web Applications with Docker
Olivia Novak
Dev Intern · Leapcell

Introduction
In the world of cloud-native development, Docker has become an indispensable tool for packaging and deploying applications. For Rust-based web applications, the pursuit of lean, secure, and efficient deployments is particularly crucial. While Rust is celebrated for its performance and memory safety, the resulting Docker images can sometimes be larger than anticipated, potentially leading to increased attack surfaces and slower deployment times. This article delves into how we can leverage advanced Docker techniques – specifically, multi-stage builds and Distroless images – to dramatically shrink the footprint and bolster the security of our Rust web application containers. By doing so, we not only optimize resource utilization but also create a more resilient and trustworthy deployment environment, moving us closer to the ideal of truly "production-ready" applications.
Understanding the Fundamentals
Before diving into the practicalities, let's establish a common understanding of the core concepts that underpin our strategy for building minimal and secure Rust Docker images.
Docker Images and Layers: A Docker image is a lightweight, standalone, executable package of software that includes everything needed to run an application: code, runtime, system tools, libraries, and settings. Images are built up from a series of layers, where each instruction in a Dockerfile creates a new layer. Reusing layers can speed up builds and reduce image sizes.
Rust Toolchain: The Rust ecosystem provides rustc (the compiler), cargo (the build system and package manager), and various other tools. For building applications, these tools are essential, but they are not required at runtime.
Multi-stage Builds: This Docker feature allows you to use multiple FROM statements in your Dockerfile. Each FROM instruction starts a new build stage named "FROM stage". You can selectively copy artifacts from one stage to another, effectively discarding everything else. This is a powerful technique for separating build-time dependencies from runtime dependencies.
Distroless Images: Developed by Google, Distroless images are "language-specific base images, containing only your application and its runtime dependencies." They contain almost no operating system components, package managers, shells, or other utilities that are commonly found in standard base images like Ubuntu or Alpine. The primary benefit is a significant reduction in image size and a much smaller attack surface.
Attack Surface: This refers to the sum of all points where an unauthorized user can try to enter data to or extract data from an environment. By reducing the number of unnecessary components in a Docker image, we inherently shrink the attack surface, making it harder for malicious actors to exploit known vulnerabilities in system libraries or utilities.
Crafting Minimal and Secure Docker Images
Our goal is to build a Docker image for a simple Rust web application using the Actix Web framework. We'll demonstrate both multi-stage builds and Distroless images to achieve our objective.
The Rust Web Application
Let's start with a basic Actix Web application. Create a new Rust project:
cargo new --bin my-rust-app cd my-rust-app
Add actix-web to your Cargo.toml:
# Cargo.toml [package] name = "my-rust-app" version = "0.1.0" edition = "2021" [dependencies] actix-web = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Now, replace the content of src/main.rs with a simple "Hello, world!" server:
// src/main.rs use actix_web::{get, App, HttpServer, Responder}; #[get("/")] async fn hello() -> impl Responder { "Hello from Rust Web App!" } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new().service(hello) }) .bind(("0.0.0.0", 8080))? .run() .await }
This application listens on port 8080 and responds with "Hello from Rust Web App!" at the root path.
Multi-Stage Build: The First Step Towards Minimization
A multi-stage build is crucial for separating the heavy build environment from the lean runtime environment.
Let's create a Dockerfile in the root of our project:
# Dockerfile # Stage 1: Builder # Use a specific Rust image as our build environment. # We often choose a 'buster' or 'bullseye' variant for broader compatibility # during compilation, but 'slim' or 'alpine' can also be used if deps are minimal. FROM rust:1.75-bookworm AS builder # Set the working directory inside the container WORKDIR /app # Copy Cargo.toml and Cargo.lock first to allow Docker to cache dependencies # If these files don't change, subsequent builds can reuse the cached layer COPY Cargo.toml Cargo.lock ./ # Build dependencies (empty main.rs to force dependency build only) # This step is critical for caching layer optimizations. # If dependencies haven't changed, this layer will be reused. RUN mkdir src \ && echo "fn main() {}" > src/main.rs \ && cargo build --release \ && rm -rf src # Copy the actual source code COPY src ./src # Build the release binary # We use target/release because cargo build --release puts the binary there RUN cargo build --release # Stage 2: Runner # This is our runtime stage. We'll use a Debian 'buster-slim' image, # which is much smaller than the full Rust image but still includes basic libc. FROM debian:bookworm-slim AS runner # Set the working directory WORKDIR /app # Copy the compiled binary from the 'builder' stage # Ensure that the binary name matches your project name (my_rust_app) COPY /app/target/release/my-rust-app ./my-rust-app # Expose the port the application listens on EXPOSE 8080 # Run the application CMD ["./my-rust-app"]
Explanation:
-
builderStage:FROM rust:1.75-bookworm AS builder: We start with an official Rust image, which includesrustc,cargo, and all necessary build tools. We name this stagebuilder.WORKDIR /app: Sets the working directory.COPY Cargo.toml Cargo.lock ./: Copies only the manifest files. This crucial step allows Docker to cache thecargo build --releasecommand for dependencies ifCargo.tomlandCargo.lockhaven't changed, significantly speeding up rebuilds.RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src: This is a trick to build only dependencies. We create a dummymain.rs, build the project (which resolves and compiles dependencies), then remove the dummy. This ensures the dependency layer is cached.COPY src ./src: Copies the actual source code.RUN cargo build --release: Compiles our application into an optimized release binary.
-
runnerStage:FROM debian:bookworm-slim AS runner: We switch to a much smaller base image,debian:bookworm-slim. This image only provides the bare minimum, primarilylibc, which Rust binaries rely on. We name this stagerunner.WORKDIR /app: Same working directory.COPY --from=builder /app/target/release/my-rust-app ./my-rust-app: This is the core of multi-stage builds. We copy only the compiled binary from thebuilderstage into our leanrunnerstage. All the build tools, source code, and intermediate artifacts from thebuilderstage are discarded.EXPOSE 8080: Documents the port the application listens on.CMD ["./my-rust-app"]: Defines the command to run our application when the container starts.
To build and run this image:
docker build -t my-rust-app-multi-stage . docker run -p 8080:8080 my-rust-app-multi-stage
You can verify the image size: docker images | grep my-rust-app-multi-stage. You'll notice it's significantly smaller than if you built it without multi-stage.
Distroless: The Apex of Minimization and Security
Distroless images push the concept of minimization even further by removing almost everything from the base image. This dramatically reduces the potential attack surface.
Let's modify our Dockerfile to use a Distroless image for the runner stage:
# Dockerfile (Distroless version) # Stage 1: Builder (same as before) FROM rust:1.75-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN mkdir src \ && echo "fn main() {}" > src/main.rs \ && cargo build --release \ && rm -rf src COPY src ./src RUN cargo build --release # Stage 2: Runner - Using Google's Distroless static base image # This image contains the bare minimum needed for a statically linked (or almost static) binary. # Rust binaries are often dynamically linked by default, relying on libc. # For distroless, we often need to ensure our Rust binary is truly static or # use a distroless image that provides the necessary dynamic libraries (like `gcr.io/distroless/cc-debian12`). # For applications with dynamic linking (the default for Rust), `gcr.io/distroless/cc-debian12` # is usually the best choice as it provides glibc. FROM gcr.io/distroless/cc-debian12 AS runner # Set the working directory WORKDIR /app # Copy the compiled binary from the 'builder' stage COPY /app/target/release/my-rust-app ./my-rust-app # Expose the port EXPOSE 8080 # The Distroless image does not have a shell, so we must use the absolute path to our binary # if it's not in the PATH, or directly execute it. # Specify the command to run the application CMD ["./my-rust-app"]
Key Change:
FROM gcr.io/distroless/cc-debian12 AS runner: We switch to thecc-debian12variant of Distroless, which includesglibc– a standard C library that dynamically linked Rust binaries often depend on. This is crucial as Rust binaries are typically dynamically linked by default. If your Rust application is truly statically linked (e.g., usingmusltarget), you might usegcr.io/distroless/static-debian12.
To build and run this Distroless image:
docker build -t my-rust-app-distroless . docker run -p 8080:8080 my-rust-app-distroless
Comparing the image size: docker images | grep my-rust-app-distroless. You will observe a further significant reduction in size compared to the debian:bookworm-slim based image.
Further Optimization: Musl and FROM scratch
For the absolute smallest possible image, you can compile your Rust application against the musl target, which produces a fully static binary. This allows you to use FROM scratch, the smallest possible Docker image (literally empty).
First, add the musl toolchain:
rustup target add x86_64-unknown-linux-musl
Then, modify the Dockerfile:
# Dockerfile (Musl + scratch version) # Stage 1: Builder # Use a Rust image that supports the musl target. Often, the default `rust:stable` works, # but sometimes a specific `rust:stable-slim-buster` or `rust:stable-alpine` is better suited. # For simplicity, we'll assume the base rust image has musl support. FROM rust:1.75-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock ./ # Use a dummy main.rs to build dependencies and cache the layer RUN mkdir src \ && echo "fn main() {}" > src/main.rs \ && cargo build --release --target x86_64-unknown-linux-musl \ && rm -rf src COPY src ./src # Build the release binary for the musl target RUN cargo build --release --target x86_64-unknown-linux-musl # Stage 2: Runner - FROM scratch # This is the smallest possible base image, containing absolutely nothing. FROM scratch AS runner # Set the working directory WORKDIR /app # Copy the statically linked binary from the 'builder' stage # The binary is now self-contained and does not require external libraries like glibc. COPY /app/target/x86_64-unknown-linux-musl/release/my-rust-app ./my-rust-app # Expose the port EXPOSE 8080 # Specify the command to run the application CMD ["./my-rust-app"]
Building and running this image:
docker build -t my-rust-app-musl-scratch . docker run -p 8080:8080 my-rust-app-musl-scratch
This musl and FROM scratch approach typically yields the smallest possible Docker image for a Rust application, as it only contains your compiled binary. However, be aware that static linking can sometimes lead to larger binary sizes (due to bundling libraries) and might have compatibility issues with some C dependencies that expect dynamic linking. For most web applications, the Distroless cc-debian12 approach strikes a good balance between size, security, and compatibility.
Conclusion
By strategically employing multi-stage Docker builds and Distroless images, we can significantly reduce the size and fortify the security of our Rust web application containers. This not only optimizes resource consumption and accelerates deployment times but also drastically shrinks the attack surface, creating a more robust and trustworthy production environment. Building minimal images is a critical step towards operating secure and efficient cloud-native Rust applications.

