Deploying a Rust API — The Runtime Is Tiny, the Build Is the Problem

·5 min read·Updated
rustaxumtokiodockerbuilddeployment

Rust produces close to an ideal container workload: a single compiled binary, no interpreter, no dependency resolution at startup, and memory use that makes a fractional-RAM allocation feel generous. The catch is at the other end. cargo build --release is the most demanding build of any stack you're likely to deploy here, and builds run in their own container with roughly 0.75 GB of RAM and half a vCPU. For anything past a toy project, expect to build in CI and push the image — and if you'd rather build on the platform, the lever that matters most is limiting build parallelism, not optimisation flags.

The runtime half is genuinely easy

Briefly, because it's the same story as a Go binary: compile in one stage, copy the binary into a minimal runtime image, and you get a container measured in tens of megabytes that starts instantly and idles on very little memory.

An axum or actix-web service with a small dependency tree is comfortable well below the resources most people allocate to it. If you're choosing a language for something long-running and cost-sensitive, Rust and Go are the two that make a small allocation feel roomy.

So everything that follows is about the build.

Why the build is hard

rustc and LLVM are memory-hungry, and Cargo compiles multiple crates in parallel by default — each in its own process, each with its own peak. A dependency tree with tokio, serde, sqlx, and a web framework produces hundreds of crates, several of which are individually heavy.

The failure mode is the same one you get from any out-of-memory kill in a build: compilation stops partway through with no explicit error about memory. It looks like a hang, or a crash in whichever crate happened to be compiling. Rebuilding often fails at a different crate, which is a strong hint that memory rather than code is the problem.

Fixes, in the order I'd try them

1. Build in CI and push the image. GitHub Actions runners have far more memory than the build container, and Rust's compile times reward proper dependency caching, which CI does well. For a real Rust project this is the answer, not a workaround.

2. Limit build parallelism. If you build on the platform, this is the single biggest lever on peak memory — bigger than any optimisation setting:

ENV CARGO_BUILD_JOBS=1

One rustc process at a time means one peak instead of several overlapping. The build gets slower; it also starts finishing.

3. Tune the release profile — carefully.

[profile.release]
codegen-units = 1
strip = true
panic = "abort"

codegen-units = 1 reduces intra-crate parallelism, which lowers peak memory and improves optimisation at the cost of speed. strip removes symbols from the binary, and panic = "abort" drops unwinding machinery — both shrink the output.

One to avoid if your build is failing: lto = "fat". Link-time optimisation produces smaller, faster binaries by holding much more of the program in memory at link time. It makes the build hungrier, which is exactly the wrong direction when the build container is what you're fighting. Add it once you're building in CI, not before.

4. Cache dependencies in their own layer. Copy your manifests, build a stub, then copy the real source:

COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
COPY src ./src
RUN touch src/main.rs && cargo build --release

Now a code change recompiles your crate rather than the whole dependency tree. It doesn't lower peak memory, but it dramatically reduces how often you pay it.

Tell tokio how many threads it may use

This one bites Rust services specifically, and it's a one-line fix with no code change.

Tokio's multi-thread runtime defaults, per its documentation, to a worker count where "the default value is the number of cores available to the system." A container sees the host's cores, not your CPU quota — so on a large machine, tokio can spin up dozens of worker threads to share a slice of a single core.

That's counterproductive. Your CPU allocation is a time budget per 100ms window, shared across the whole container, so more threads exhaust it faster and add scheduling overhead.

The docs also note the setting "will override the value read from environment variable TOKIO_WORKER_THREADS" — meaning that variable is honoured unless your code overrides it. So set it as an environment variable on the app:

TOKIO_WORKER_THREADS=2

On anything under 1 vCPU, a small number is right. If you build the runtime yourself, set worker_threads explicitly instead — and consider Runtime::new_current_thread() for a service that's mostly waiting on I/O.

A Dockerfile that works

FROM rust:1-slim AS build
WORKDIR /src
ENV CARGO_BUILD_JOBS=1
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
COPY src ./src
RUN touch src/main.rs && cargo build --release

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/myapp /usr/local/bin/myapp
CMD ["myapp"]

Two notes. ca-certificates is not optional if your service makes outbound HTTPS calls — its absence produces certificate-verification errors that look like network problems. And bind to 0.0.0.0, not localhost, or the router can't reach you and nothing will probe your app to notice.

musl and scratch: worth it, later

You can target x86_64-unknown-linux-musl and ship a statically linked binary in a scratch image, which is the smallest possible result. It's also where Rust deployment gets fiddly — crates that link C libraries need musl-compatible builds, and OpenSSL is the usual source of pain.

The clean way around that is to avoid the C dependency entirely: prefer rustls over native-tls in your HTTP client and TLS stack, and the OpenSSL problem disappears rather than being solved.

My honest recommendation: start with debian:bookworm-slim, which is small enough that image size isn't your bottleneck, and move to musl when you have a reason. Note that a scratch image is still debuggable here — a static busybox is mounted into every container, so the shell works even with nothing in your image.

FAQ

Why does my Rust build fail on the platform but work locally?

The build container has around 0.75 GB of RAM. Cargo compiles crates in parallel, and several rustc processes can exceed that. Set CARGO_BUILD_JOBS=1 or build in CI.

Can I deploy Rust by uploading a zip?

No. The build agent detects Python, Node.js, and prebuilt .NET. Rust needs a Dockerfile, pushed by git or as an image.

Should I enable LTO?

Not while the build is what's failing — LTO increases peak memory at link time. Enable it once you're building in CI.

How many tokio worker threads should I use?

Few. The default is the host's core count, which has nothing to do with your quota. Set TOKIO_WORKER_THREADS to a small number on a fractional CPU.

Do I need a musl static binary?

Not to start. A slim Debian runtime image is small enough for most purposes and avoids the C-linking problems musl introduces.

How much memory does a Rust API need at runtime?

Usually very little — this is the stack where a small allocation goes furthest. Size the build, not the app.

Wrapping up

Rust inverts the usual hosting problem. Most stacks are easy to build and expensive to run; Rust is cheap to run and expensive to build, and the constrained thing here is the builder. Cap the parallelism, skip LTO until later, cache your dependency layer, and tell tokio it doesn't have sixteen cores. Once the binary exists, it's one of the least demanding things you can host.