2025-09-26 Carl Riis
This post explains a trick I enjoy doing in Rust: shove multiple different services into the same output binary. Is this a good idea to do? In general, no. But for smaller, maybe internal projects, it can be nice to have the ultra-simplified deployment.
I feel that personal/team headaches correlates directly with the number of separate deployment mechanisms you need to maintain. This is a strategy I like when scope/setup allows for it.
To illustrate what I mean, I’ll create an example server in this post. It will be responsible for the following things:
The reference code can be found here.
Pretty common thing to have in your binary. I prefer axum
for this.
In this project, I use sled
(think similar use cases as SQLite). It’s a simple, pure-Rust, embeddable database.
No CDN for us. Our backend will serve the frontend, just as it does with the REST endpoints.
What’s more, our static frontend files won’t be in some static/
directory next to our binary - they will be inside the binary. Specifically, the .rodata
section.
There’s an awesome crate called rust-embed
that helps us with this. We’ll use it to embed our frontend build directory at compile-time.
Inspecting the binary shows the embedded minified JavaScript with filenames:
Svelte is my frontend tool of choice, and I’ll be using it for this project. Its static SPA builds are excellent, even handling dynamic routes like /items/[id]/sub/[sub_id]/[...more]
without a special server. You can’t do that with Next.js.
This snippet has the handler I’ll be using to serve the frontend:
#[derive(Embed)]
#[folder = "./frontend/build"]
struct Assets;
#[tokio::main]
async fn main() {
let db = open_db();
let state = AppState { db: Arc::new(db) };
let app = Router::new()
.route("/api/items", get(list_items))
.route("/api/items/{id}", get(single_item))
.with_state(state)
.fallback(static_handler);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn static_handler(uri: Uri, headers: HeaderMap) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
let accept = headers
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
// SvelteKit puts hashed files in `/_app/immutable/` (safe to cache forever)
// E.g.: `/_app/immutable/chunks/1D3s5Tee.js`
let immutable = path.starts_with("_app/immutable/");
let candidates = if immutable {
// Build has pre-compressed files e.g.: 1D3s5Tee.js, 1D3s5Tee.js.br, ...
let mut v = Vec::with_capacity(3);
if accept.contains("br") {
v.push(format!("{path}.br"));
}
if accept.contains("gzip") {
v.push(format!("{path}.gz"));
}
v.push(path.to_string());
v
} else {
vec![path.to_string()]
};
if let Some((name, content)) = candidates
.into_iter()
.find_map(|p| Assets::get(&p).map(|c| (p, c)))
{
let mime = mime_guess::from_path(path).first_or_octet_stream();
let mut builder = Response::builder()
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::VARY, "Accept-Encoding");
if immutable {
builder = builder.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable");
}
for (suffix, enc) in [(".br", "br"), (".gz", "gzip")] {
if name.ends_with(suffix) {
builder = builder.header(header::CONTENT_ENCODING, enc);
break;
}
}
return builder.body(Body::from(content.data)).unwrap();
}
// SPA fallback
match Assets::get("index.html") {
Some(content) => Html(content.data).into_response(),
None => (StatusCode::NOT_FOUND, "404").into_response(),
}
}
To have encrypted, trusted connections to our server, we need HTTP over TLS (https://...
). But this requires a way to manage certificates and their renewal.
Typically, you’d use Certbot for this. However, it’s written in Python and runs outside our server, so that’s out.
Instead, we’ll be using the awesome crate rustls-acme
. It will work with our axum
web server to request, cache, and renew certificates.
To play with this, I mapped app.test
to 127.0.0.1
in my /etc/hosts
. Then, to test the ACME exchange locally, I used pebble
to issue challenges to my server.
You can see how my main function changed here:
#[tokio::main]
async fn main() {
let root_cert = include_bytes!("../pebble.minica.pem");
let mut root_store = rustls::RootCertStore::empty();
let cert = rustls_pemfile::certs(&mut root_cert.as_slice())
.next()
.expect("Failed to parse certificate")
.expect("No certificate found");
root_store
.add(cert) // We only trust Pebble for this test
.expect("Failed to add certificate to root store");
let client_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let mut acme_state = AcmeConfig::new(vec!["app.test".to_string()])
.cache_option(Some(DirCache::new("./data/acme")))
.directory("https://localhost:14000/dir") // We expect Pebble to run here
.client_tls_config(Arc::new(client_config))
.challenge_type(Http01)
.state();
let acceptor = acme_state.axum_acceptor(acme_state.default_rustls_config());
let acme_challenge_tower_service: TowerHttp01ChallengeService =
acme_state.http01_challenge_tower_service();
tokio::spawn(async move {
loop {
match acme_state.next().await.unwrap() {
Ok(ok) => println!("event: {:?}", ok),
Err(err) => println!("error: {:?}", err),
}
}
});
let db = open_db();
let state = AppState { db: Arc::new(db) };
let app = Router::new()
.route("/api/items", get(list_items))
.route("/api/items/{id}", get(single_item))
.with_state(state)
.route_service(
"/.well-known/acme-challenge/{challenge_token}",
acme_challenge_tower_service,
)
.fallback(static_handler);
let http_addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, 80));
let https_addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, 443));
let http_future = bind(http_addr).serve(app.clone().into_make_service());
let https_future = bind(https_addr)
.acceptor(acceptor)
.serve(app.into_make_service());
try_join!(https_future, http_future).unwrap();
}
It’s honestly nice having such simple control over a process that usually requires a managed solution because it’s so cumbersome.
I imported Pebble’s certificate authority (CA) into Firefox to view my frontend in its full trusted HTTPS glory:
For fun, I decided to record how my binary beefed up over time.
1.5M
- barebones axum
hello world2.5M
- added sled
and serde
for data storage and serialization2.9M
- added static file serving (frontend/build/
was 356K
)8.5M
- added certificate management4.5M
- after applying size-optimization tricksThis was without scrutinizing Cargo.toml
or trimming dependencies.
Why stop here? More things could be packed into the binary:
libc
.tar
files of your favorite containersMy Rust binaries will continue to absorb more responsibilities over time, and no one can stop me.