Deploying a Rust Binary to a Linux Server
Rust compiles to a single self-contained binary, which makes deployment refreshingly simple. This end-to-end guide covers release builds, the glibc-vs-musl question for static binaries, copying the binary over, supervising it with systemd, keeping secrets in an environment file, locking the firewall with ufw, and scripting a repeatable deploy.
Introduction
One of Rust's quiet superpowers for deployment is that it compiles to a single, self-contained native binary. There is no
runtime to install on the server, no interpreter, no virtual machine, no dependency tree to reconcile — you build one file
and run it. This guide walks through taking a Rust web service from cargo build on your machine to a running,
supervised service on a Linux server, and the handful of details that make the difference between "it runs" and "it runs in
production".
Building a release binary
Development builds are unoptimised and slow. For deployment you always want a release build, which enables optimisations and strips debug assertions:
cargo build --release
# binary lands at target/release/myapp
You can shrink the binary further with a few profile settings in Cargo.toml:
[profile.release]
opt-level = 3 # full optimisation (default for release)
lto = true # link-time optimisation: smaller, faster
codegen-units = 1 # better optimisation at the cost of build time
strip = true # strip symbols from the binary
panic = "abort" # smaller binary if you don't unwind
glibc vs. musl: will it run on the server?
This is the detail that catches people out. By default, cargo build on Linux links against glibc,
and it links dynamically — the resulting binary depends on the glibc version present on the build machine. If your
server has an older glibc than your build box, the binary may refuse to start with a "version not found" error.
There are two robust ways around this:
- Build on (or matching) the target OS. Build inside a container or VM whose distribution and glibc match the server. Simple and reliable.
- Build a fully static binary with musl. The
x86_64-unknown-linux-musltarget links the C library statically, producing a binary with essentially no external dependencies that runs on virtually any Linux:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# fully static binary at target/x86_64-unknown-linux-musl/release/myapp
The musl route is popular precisely because a static binary sidesteps the entire "which glibc" question. Verify what you built
with ldd target/.../myapp — a musl static binary reports "not a dynamic executable".
Getting the binary onto the server
Because it's just one file, copying it over is trivial. Use scp for a one-off or rsync for repeated
deploys (it only transfers what changed):
# One-off copy
scp target/release/myapp user@server:/tmp/myapp
# Repeatable deploy
rsync -avz target/release/myapp user@server:/tmp/myapp
Then, on the server, install it into place with the right ownership and permissions:
sudo mkdir -p /opt/myapp
sudo mv /tmp/myapp /opt/myapp/myapp
sudo chown -R myapp:myapp /opt/myapp
sudo chmod 755 /opt/myapp/myapp
If your service ships assets it loads at runtime — templates, a static/ directory, a config file — copy those
alongside the binary and make sure the service's WorkingDirectory points at that folder so relative paths
resolve.
Running it as a service
Never run a production service by hand in an SSH session — the moment you disconnect or it crashes, it's gone. Supervise it
with systemd. A minimal unit at /etc/systemd/system/myapp.service:
[Unit]
Description=My Rust service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/myapp
EnvironmentFile=-/opt/myapp/myapp.env
AmbientCapabilities=CAP_NET_BIND_SERVICE
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
journalctl -u myapp -f
The AmbientCapabilities=CAP_NET_BIND_SERVICE line lets the unprivileged myapp user bind ports 80/443
without running as root. (See our companion article on writing systemd services for the full breakdown.)
Configuration and secrets
Keep configuration out of the binary. The idiomatic pattern is an EnvironmentFile holding environment variables,
readable only by the service user:
sudo tee /opt/myapp/myapp.env > /dev/null <<'EOF'
DATABASE_URL=postgres://...
API_KEY=...
BIND_ADDR=0.0.0.0:443
EOF
sudo chown myapp:myapp /opt/myapp/myapp.env
sudo chmod 600 /opt/myapp/myapp.env
Crucially, keep this file out of your deploy package so a redeploy never overwrites production secrets — replace only the binary and assets, and leave config in place.
Locking down the firewall
A fresh server should expose only the ports it needs. On Ubuntu/Debian, ufw makes this straightforward — allow
SSH and your web ports, then default-deny everything else:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw enable
For extra safety, restrict SSH to known admin addresses rather than the whole internet:
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
Toward zero-downtime deploys
A plain systemctl restart drops in-flight connections for a moment. For most services that's acceptable; for
ones on the critical path it isn't. Two ways to smooth it over:
- Graceful shutdown in the app. Have the process handle
SIGTERMand drain in-flight requests before exiting, so a restart finishes existing work instead of severing it. - Run behind a reverse proxy. With a proxy in front, you can start a new instance, let it warm up, shift traffic over, and retire the old one — a rolling swap with no dropped requests.
A repeatable deploy script
Wrap the whole thing in a script so every deploy is identical and boring:
#!/usr/bin/env bash
set -euo pipefail
HOST="user@server"
cargo build --release --target x86_64-unknown-linux-musl
BIN=target/x86_64-unknown-linux-musl/release/myapp
rsync -avz "$BIN" "$HOST:/tmp/myapp"
ssh "$HOST" '
sudo mv /tmp/myapp /opt/myapp/myapp &&
sudo chown myapp:myapp /opt/myapp/myapp &&
sudo systemctl restart myapp &&
systemctl is-active myapp
'
echo "deployed."
Conclusion
Deploying Rust is refreshingly simple because there's just one artifact to move: build a release binary (statically with musl if you want to forget about glibc versions entirely), copy it to the server, install it with sane ownership, and let systemd supervise it with automatic restarts and journald logging. Keep secrets in an environment file that deploys don't touch, lock the firewall down to the ports you actually serve, and script the whole flow so it's repeatable. That's the same disciplined, lightweight deployment model ARC2 Proxy itself uses — a single native binary, supervised by systemd, behind a locked-down firewall.