Build a fully static Rust single-binary service with embedded assets, ETag caching, musl linking, and hardened systemd supervision.
Shipping a binary together with a tree of static files creates recurring operational failures: missing assets after a partial deploy, incorrect permissions, and drift between what was tested and what runs in production. The single-binary model collapses application code and static assets into one ELF file that can be copied to a host and supervised by systemd with almost no external dependencies.
The decision to adopt this pattern rests on three measurable constraints: atomic updates, a minimal filesystem footprint, and the ability to run on hosts that provide only a compatible kernel. Rust, combined with rust-embed and a carefully configured musl build, satisfies those constraints when the asset set is bounded and changes infrequently.
Architectural Constraints and Trade-offs
Embedding assets increases binary size linearly with the total asset volume. Any change to HTML, CSS, or images requires a rebuild. In exchange, deployment reduces to replacing a single file and restarting a systemd unit. For internal tools, admin UIs, small APIs, and edge appliances this trade-off is usually favorable. Large, frequently updated frontends are better served from a CDN; the single-binary approach targets the opposite case.
Axum 0.7+ Handler with ETag and Immutable Caching
Axum 0.7 removed axum::Server. The current pattern binds a tokio::net::TcpListener and passes it to axum::serve. Because embedded assets are immutable for the lifetime of the binary, the handler can emit strong ETags derived from the compile-time SHA-256 hash that rust-embed already computes, and can answer matching If-None-Match requests with 304 Not Modified.
use axum::{
body::Body,
extract::Path,
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use tokio::net::TcpListener;
#[derive(RustEmbed)]
#[folder = "static/"]
struct Assets;
async fn static_handler(path: Option<Path<String>>, headers: HeaderMap) -> impl IntoResponse {
let raw_path = path.map(|p| p.0).unwrap_or_else(|| "index.html".to_string());
// SPA fallback
let target_path = if Assets::get(&raw_path).is_some() {
raw_path
} else {
"index.html".to_string()
};
match Assets::get(&target_path) {
Some(content) => {
let mime = mime_guess::from_path(&target_path).first_or_octet_stream();
let etag_value = format!("\"{}\"", hex::encode(content.metadata.sha256_hash()));
// Zero-copy 304 path
if let Some(req_etag) = headers.get(header::IF_NONE_MATCH) {
if req_etag == etag_value.as_str() {
return StatusCode::NOT_MODIFIED.into_response();
}
}
let mut response_headers = HeaderMap::new();
response_headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).unwrap(),
);
response_headers.insert(
header::ETAG,
HeaderValue::from_str(&etag_value).unwrap(),
);
response_headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
(StatusCode::OK, response_headers, Body::from(content.data)).into_response()
}
None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new()
.route("/", get(|headers: HeaderMap| static_handler(None, headers)))
.route(
"/*path",
get(|path: Path<String>, headers: HeaderMap| static_handler(Some(path), headers)),
);
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
let listener = TcpListener::bind(addr).await?;
println!("listening on http://{}", addr);
axum::serve(listener, app).await?;
Ok(())
}The Cache-Control: immutable directive tells browsers and CDNs that the byte content will never change for that URL while the binary remains the same. Combined with the ETag check, repeat visitors avoid transferring the payload at all.
Pure Static MUSL Build
A musl target produces a fully static ELF only when no crate pulls in a C library such as OpenSSL. Force TLS stacks that are pure Rust:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
rust-embed = "8"
mime_guess = "2"
hex = "0.4"
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] }
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
strip = trueBuild and verify:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
file target/x86_64-unknown-linux-musl/release/single-binary-app
# expected: statically linked, stripped
ldd target/x86_64-unknown-linux-musl/release/single-binary-app
# expected: not a dynamic executableIf ldd still reports dynamic libraries, a transitive dependency is linking against glibc or OpenSSL and must be replaced or feature-gated.
Hardened Systemd Unit
[Unit]
Description=Rust Single-Binary Monolith Service
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=appdaemon
Group=appdaemon
ExecStart=/usr/local/bin/single-binary-app
Restart=on-failure
RestartSec=3s
LimitNOFILE=65536
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
MemoryDenyWriteExecute=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadOnlyPaths=/usr/local/bin/single-binary-app
[Install]
WantedBy=multi-user.targetMemoryDenyWriteExecute prevents the process from mapping memory that is both writable and executable, raising the cost of many memory-corruption exploits. The remaining directives shrink the visible filesystem and kernel interfaces to the minimum required for a network service.
Memory Layout of Embedded Assets
rust-embed injects asset bytes into the ELF .rodata section at compile time. The kernel maps that section read-only and shareable across processes:
Serving an embedded file is an (O(1)) pointer retrieval from the mapped segment; no heap allocation or disk I/O is required for the payload itself. When the client already holds a matching ETag, the handler returns 304 and the process touches neither the asset bytes nor the network payload path.
Deployment Sequence
- CI produces the musl release binary.
- The artifact is uploaded to object storage or a release registry.
- On the host: download, install to /usr/local/bin, systemctl restart single-binary-app.
- Systemd owns supervision, journald logging, and restart policy.
When the Pattern Fits
- Internal tools and admin interfaces with stable asset sets
- Small public APIs that also serve a modest frontend
- Edge or appliance-style deployments where installing a package manager is undesirable
References
- Axum documentation (0.7+ serve API): https://docs.rs/axum
- rust-embed crate: https://crates.io/crates/rust-embed
- musl target and static linking notes: https://doc.rust-lang.org/rustc/platform-support.html
- systemd.exec hardening directives: https://www.freedesktop.org/software/systemd/man/systemd.exec.html
.jpg)
