Deploying a Rust Service to a Plain VPS With Systemd
Run a Rust binary on a Linux server under systemd: a locked down unit file, a dedicated user, environment files, restart policy, logs through journald and a simple release procedure.

The simplest production deployment of a Rust service is a binary, a user and a unit file. It has been reliable for many years, it has no daemon besides the init system, and it gives you restarts, logging and sandboxing. This post sets it up for a service listening on port 3000, behind a reverse proxy. It is the plain-VPS option in shipping Rust to production, and uses the shutdown behavior from Axum graceful shutdown.
Debian 12 or Ubuntu 24.04, systemd 252 or newer, Rust 1.90.
Build and copy
cargo build --release --locked
scp target/release/myapp [email protected]:/tmp/myappBuild on a machine with the same CPU architecture and libc as the server, or use the musl target for a static binary (Rust and musl). A glibc binary built on a newer distribution can refuse to start on an older one with a GLIBC_2.xx not found error.
Create the user and layout
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp
sudo mkdir -p /opt/myapp /etc/myapp
sudo install -o root -g root -m 0755 /tmp/myapp /opt/myapp/myappThe service user has no login shell and owns nothing but its own data directory.
The unit file
[Unit]
Description=My Rust service
After=network-online.target
Wants=network-online.target
[Service]
User=myapp
Group=myapp
ExecStart=/opt/myapp/myapp
EnvironmentFile=/etc/myapp/env
Restart=on-failure
RestartSec=2
TimeoutStopSec=30
KillSignal=SIGTERM
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
StateDirectory=myapp
[Install]
WantedBy=multi-user.targetTimeoutStopSec=30 is how long systemd waits after SIGTERM before SIGKILL; set your app's drain deadline below it. ProtectSystem=strict mounts the file system read-only except for StateDirectory, which systemd creates as /var/lib/myapp owned by the service user. If the app needs to write elsewhere, add ReadWritePaths=.
The environment file, readable only by root:
sudo install -m 0600 -o root -g root /dev/null /etc/myapp/env
sudo sh -c 'printf "DATABASE_URL=postgres://myapp:change-me@localhost/myapp\nRUST_LOG=info\n" > /etc/myapp/env'systemd reads it as root before dropping to the service user, so the app can use the values and ordinary users cannot read the file. See configuration from the environment.
Start and observe
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
journalctl -u myapp -fstdout and stderr go to the journal with the unit name. Structured JSON logs from tracing work well with journalctl -o cat.
A deploy and a rollback
sudo install -m 0755 /tmp/myapp /opt/myapp/myapp.new
sudo mv /opt/myapp/myapp /opt/myapp/myapp.prev
sudo mv /opt/myapp/myapp.new /opt/myapp/myapp
sudo systemctl restart myappTo roll back, move myapp.prev back and restart. Replace the file with mv rather than overwriting it in place, since writing to a running executable fails with Text file busy. A restart has a short gap; for none, use systemd socket activation or run two instances behind the proxy.
Reverse proxy
Terminate TLS in Caddy or nginx and forward to 127.0.0.1:3000. Bind the app to localhost so only the proxy can reach it. Read the client address from the proxy's forwarded headers, and trust them only from the proxy.
Limits
This is one host with no automatic failover. For high availability you need several hosts and a load balancer, and then containers and an orchestrator become attractive. The unit file options are documented in the systemd.exec manual.


