ARC2 Proxy
Linux · By Milen Denev

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.

Deploying a Rust Binary to a Linux Server

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-musl target 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 SIGTERM and 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.

Keep reading