Run a Rust Server as a systemd Service

A complete systemd unit for RustDedicated on Linux: restart policy, start limits, saving before stop, journalctl logs, several servers and crash exits.

Last updated Verified on Ubuntu 26.04.1 LTS, systemd 259, Rust build 25581777, 2026-09-28

On this page

Running RustDedicated in a screen or tmux session works until the box reboots or the server crashes at 4 a.m. A systemd service fixes both: the server starts on boot, comes back after a crash, and its output lands in the journal with timestamps. This guide builds a unit file modelled on the one the Panelra agent writes on real hosts, and explains the settings that actually matter for Rust.

It assumes Rust is already installed with SteamCMD in its own directory, for example /opt/rust-servers/instance-1, and that you have root on an Ubuntu or Debian machine with systemd.

The unit file

This is the unit, one file per server, saved as /etc/systemd/system/rust-instance-1.service:

[Unit]
Description=Rust Server instance-1
After=network.target
StartLimitIntervalSec=0

[Service]
Type=simple
User=root
WorkingDirectory=/opt/rust-servers/instance-1

# RCON password and other secrets live outside the unit
EnvironmentFile=/opt/rust-servers/instance-1/.env

ExecStart=/opt/rust-servers/instance-1/RustDedicated \
    -batchmode \
    -nographics \
    +server.identity instance-1 \
    +server.port 28015 \
    +server.queryport 28017 \
    +app.port 28082 \
    +rcon.port 28016 \
    +rcon.web 1 \
    +rcon.password ${RCON_PASSWORD}

StandardOutput=journal
StandardError=journal
SyslogIdentifier=rust-instance-1

Restart=always
RestartSec=5

LimitNOFILE=100000

[Install]
WantedBy=multi-user.target

What each part does:

  • Type=simple fits RustDedicated: it stays in the foreground and does not fork.
  • WorkingDirectory must be the install directory. The server finds its data and writes server/<identity>/ relative to it.
  • EnvironmentFile keeps the RCON password out of the unit file, which any local user can read with systemctl cat. systemd expands ${RCON_PASSWORD} in ExecStart from that file. The expanded password still shows up in the process command line (ps aux), so do not give untrusted users a shell on the box.
  • +server.identity names the save folder. Give every server on the host a different one.
  • Ports. 28015 (game) and 28017 (query) are UDP and must be open to players. 28082 is the Rust+ companion port (TCP). 28016 is RCON: keep it closed in your firewall and reach it over SSH or a VPN.
  • StandardOutput=journal sends the console to the journal, so you get real timestamps and rotation for free.
  • LimitNOFILE=100000 raises the open file limit. It is what the Panelra agent sets on every server.
  • User=root matches the Panelra agent, which runs servers as root. If you prefer a dedicated user, create it, chown -R the install directory to it and change this line. SteamCMD updates then have to run as that user too.

Create the environment file next to it and lock it down:

cat > /opt/rust-servers/instance-1/.env <<'EOF'
RCON_PASSWORD=change-me-to-a-long-random-string
EOF
chmod 600 /opt/rust-servers/instance-1/.env

Everything else (hostname, max players, world size, seed, save interval) belongs in server/<identity>/cfg/server.cfg, not on the command line. One rule from that file affects the service: never set server.seed 0. Pick a real number so the map is the same on every start.

Enable it and start on boot

Reload systemd so it reads the new file, enable it for boot, and start it:

systemctl daemon-reload
systemctl enable rust-instance-1.service
systemctl start rust-instance-1.service

enable creates the link from multi-user.target, which is what WantedBy=multi-user.target asks for. Check both states at any time:

systemctl is-enabled rust-instance-1.service
systemctl is-active rust-instance-1.service

Run systemctl daemon-reload again after every edit to the unit file, then restart the service for the change to apply.

Restart=always or Restart=on-failure

The default is Restart=no, which is wrong for a game server. The real choice is between two values. From the systemd.service manual, this is what triggers a restart:

Why the process endedon-failurealways
Clean exit: code 0, or SIGTERM, SIGINT, SIGHUP, SIGPIPEnoyes
Non-zero exit codeyesyes
Killed by another signal (SIGKILL, SIGSEGV, SIGABRT)yesyes
Killed by the OOM killeryesyes
Start timeoutyesyes

A systemctl stop never triggers a restart with either setting, because systemd knows you asked for it.

The difference is the clean exit. With Restart=always, an in-game or RCON quit brings the server straight back, and the RCON restart <seconds> command works as players expect: the countdown ends, the process exits cleanly, and systemd starts it again. With Restart=on-failure, restart behaves like quit and the server stays down until you start it.

Panelra uses Restart=always, because restart countdowns and updates rely on the process coming back. If you want quit to mean "stay down", use on-failure and run restarts with systemctl restart.

Keep RestartSec=5. The default is 100 ms, which turns a crash loop into a tight loop that hammers the disk and the journal.

The start limit that silently stops restarts

systemd rate-limits starts: by default a unit that starts more than 5 times within 10 seconds is not allowed to start again and is put in the failed state. The limit counts automatic restarts too, not only manual starts. With the default RestartSec of 100 ms, a server that fails right away (a missing file, a bad argument) hits it within a second or two and then simply stays down. RestartSec=5 alone keeps you under the default limit, but that margin is gone as soon as someone shortens the delay, so switch the limit off explicitly.

StartLimitIntervalSec=0 in the [Unit] section turns the limit off. The unit the Panelra agent writes uses the older spelling, StartLimitInterval=0 in [Service], which systemd still accepts. On the test host (systemd 259) the effective values are:

systemctl show rust-instance-1.service -p Restart,RestartUSec,StartLimitIntervalUSec,StartLimitBurst,TimeoutStopUSec,NRestarts
Restart=always
RestartUSec=5s
StartLimitIntervalUSec=0
StartLimitBurst=5
TimeoutStopUSec=1min 30s
NRestarts=0

StartLimitIntervalUSec=0 confirms the limit is off. NRestarts counts automatic restarts since the unit was last started by hand, which makes it a cheap crash counter. If a unit already hit the limit, clear it before starting:

systemctl reset-failed rust-instance-1.service
systemctl start rust-instance-1.service

Turning the limit off means a server that can never boot will retry every 5 seconds forever. That is the right trade for a game server, as long as something tells you it is crash looping.

Stop without losing progress

When you run systemctl stop, systemd sends SIGTERM to every process in the unit, waits up to TimeoutStopSec (90 seconds by default), and then sends SIGKILL to whatever is left. On the test host RustDedicated reacts to SIGTERM by saving the world (Saved 69,091 ents ..., then Saving complete within a second of the Stopping line) and is fully down 6 to 7 seconds later, well inside that window.

That shutdown save is a bonus, not a plan. A hung server never gets to it, and a SIGKILL after the timeout skips it. Save explicitly first. This is the order the Panelra agent uses for every stop and restart:

  1. Announce it in chat over RCON, for example say "Server is shutting down. Saving world...".
  2. Send server.save over RCON and wait for the reply.
  3. Wait about 2 seconds so the save file is flushed to disk.
  4. systemctl stop rust-instance-1.service (or restart).

Steps 1 and 2 need an RCON client, since Rust's RCON is WebSocket based when rcon.web 1 is set. Use whichever client you already have. Autosave, set with server.saveinterval in server.cfg, is the safety net: a hard kill loses at most the progress since the last autosave.

You may be tempted to put the save into an ExecStop= line. It only runs if the service started successfully, and if RCON is not answering it delays the stop, so a hung server takes even longer to go down. If you do add one, keep it short and bounded. Leave TimeoutStopSec at the default unless your stops are really taking longer. When the timeout hits, systemd logs State 'stop-sigterm' timed out. Killing. before the status=9/KILL line, and anything not saved is lost.

To kill a frozen server that ignores SIGTERM without waiting for the timeout:

systemctl kill -s KILL rust-instance-1.service

With Restart=always the server comes back after RestartSec.

Logs with journalctl

All console output is in the journal under the unit name:

journalctl -u rust-instance-1.service -f
journalctl -u rust-instance-1.service -n 200 --no-pager
journalctl -u rust-instance-1.service --since "-1h" -o cat
journalctl -u rust-instance-1.service -b -1

The first follows live output (Ctrl-C to quit). The second prints the last 200 lines. The third shows the last hour without timestamps and hostnames, which is easier to read for stack traces. The last one shows the previous boot, useful after the host itself went down. It needs a persistent journal (/var/log/journal exists), which is the default on current Ubuntu.

Once the server has finished loading and RCON is listening, you see this line (from the test host, with the unit name shortened):

rust-instance-1[150285]: WebSocket RCON Started on :28016

Running several servers on one host

Each server needs its own install directory, its own server.identity and its own set of ports. The simplest approach is one unit file per server, which is what the Panelra agent does (one rust-instance-<id>.service per server).

If you would rather keep a single file, systemd template units do it. Save the unit as /etc/systemd/system/rust@.service and replace the per-server parts with %i, the instance name, and variables from each server's .env:

[Unit]
Description=Rust Server %i
After=network.target
StartLimitIntervalSec=0

[Service]
Type=simple
User=root
WorkingDirectory=/opt/rust-servers/%i
EnvironmentFile=/opt/rust-servers/%i/.env
ExecStart=/opt/rust-servers/%i/RustDedicated \
    -batchmode \
    -nographics \
    +server.identity %i \
    +server.port ${GAME_PORT} \
    +server.queryport ${QUERY_PORT} \
    +app.port ${APP_PORT} \
    +rcon.port ${RCON_PORT} \
    +rcon.web 1 \
    +rcon.password ${RCON_PASSWORD}
StandardOutput=journal
StandardError=journal
Restart=always
RestartSec=5
LimitNOFILE=100000

[Install]
WantedBy=multi-user.target

The second server's .env then holds its own ports, for example:

RCON_PASSWORD=another-long-random-string
GAME_PORT=28025
QUERY_PORT=28027
APP_PORT=28092
RCON_PORT=28026

Start and enable each instance by name:

systemctl daemon-reload
systemctl enable rust@instance-2.service
systemctl start rust@instance-2.service
journalctl -u rust@instance-2.service -f

Budget memory before adding servers: plan for about 12 GB of RAM and 30 GB of disk per Rust server. Two servers on a 16 GB box is how the OOM killer enters the story.

Reading crash exits

After an automatic restart, systemctl status shows the new process, and the exit status of the old one is reset (checked on systemd 259). The reason a server died only survives in the journal, as systemd lines. These are real lines from the test host:

rust-instance-...service: Main process exited, code=killed, status=9/KILL
rust-instance-...service: Failed with result 'oom-kill'.
rust-instance-...service: Scheduled restart job, restart counter is at 9.

Pull just those lines for a unit:

journalctl -u rust-instance-1.service --no-pager | grep -E "Main process exited|Failed with result|Scheduled restart"

How to read them:

  • No Failed with result line, just Deactivated successfully, is a clean exit: a quit, a finished restart countdown, or a stop you asked for.
  • code=exited, status=1/FAILURE or another number means RustDedicated exited with an error. The console lines just above it in the journal usually say why.
  • code=killed, status=9/KILL then Failed with result 'signal' means something sent SIGKILL. A line just before it tells you who: Sent signal SIGKILL to main process ... on client request is a systemctl kill, and State 'stop-sigterm' timed out. Killing. is the stop timeout. With neither, look for a kernel OOM kill (below).
  • code=killed, status=9/KILL then Failed with result 'oom-kill' is the OOM killer, reported by systemd directly.
  • code=dumped with a signal such as 11/SEGV or 6/ABRT is a crash inside the game process. The console lines above it are the place to look for the cause.

A SIGKILL can also come from the kernel OOM killer without systemd labelling it. Check the kernel log for the PID shown in brackets on the console lines before the exit:

journalctl -k --since "-2h" --no-pager | grep "Killed process"

A match reads like Out of memory: Killed process 1234 (RustDedicated) .... journalctl -k implies -b, so it only covers the current boot; add -b -1 for the previous one. The fix for repeated OOM kills is more RAM, fewer servers on the host, or fewer plugins, not a different restart setting.

Carbon: launching through carbon.sh

Carbon on Linux loads through a Doorstop preloader that must be set up in the environment before RustDedicated starts, so a plain ExecStart=.../RustDedicated never loads it. The Carbon Linux release ships carbon.sh, which does this:

SCRIPT=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
source "${SCRIPT}/carbon/tools/environment.sh"
"${SCRIPT}/RustDedicated" "$@"

environment.sh sets LD_PRELOAD to libdoorstop.so and the Doorstop variables. To run Carbon, point ExecStart at carbon.sh instead of RustDedicated and keep every argument, then daemon-reload and restart. That is exactly the change the Panelra agent makes.

carbon.sh starts RustDedicated as a child instead of replacing itself with it, so the unit's main process is bash, not RustDedicated. The default KillMode=control-group still sends SIGTERM to both on stop. Keep this in mind when you match PIDs: the one in brackets on the console lines is RustDedicated's.

One trap: if carbon/tools/environment.sh is missing, carbon.sh does not stop. It prints an error and starts vanilla Rust, so the server looks healthy but no plugins load. Look for this in the journal after a start:

carbon.sh: line 9: /opt/rust-servers/instance-1/carbon/tools/environment.sh: No such file or directory

If you see it, reinstall Carbon, or switch ExecStart back to RustDedicated.

Let Panelra manage the unit

Panelra does all of this for you: install the agent on your Linux host and it writes and enables one unit per server, saves over RCON before every stop, restarts with chat countdowns, reads crash exits from the journal (including OOM kills and crash loops) and sends the alert to the panel or Discord. It also swaps the launcher when you install or remove Carbon. You can still open the unit files and journalctl yourself at any time; nothing is hidden.

Frequently asked questions

Should I use Restart=always or Restart=on-failure for a Rust server?
Restart=always if you use the in-game restart command or want the server back after any exit. Restart=on-failure if a clean quit should leave the server down. Both restart after a crash, a SIGKILL and an OOM kill.
Why did systemd stop restarting my Rust server?
Start rate limiting. By default a unit may start 5 times in 10 seconds, and a crash loop with a short RestartSec hits that and the unit ends up failed. Set StartLimitIntervalSec=0 in the [Unit] section, then run systemctl reset-failed and start it again.
Does systemctl stop save the Rust world?
On the build we tested, yes: RustDedicated saves the world as soon as it gets SIGTERM, then shuts down. Still send server.save over RCON first and wait a couple of seconds, because a hung server or a SIGKILL after the stop timeout skips that save.
How do I tell if my Rust server was killed by the OOM killer?
Look at the unit journal. An OOM kill shows Main process exited, code=killed, status=9/KILL followed by Failed with result 'oom-kill'. The kernel log (journalctl -k) also has an Out of memory: Killed process line with the PID.
How much does Panelra cost?
Free during the open beta. Pricing will be announced before the beta ends.

Skip the manual work: install the Panelra agent

Wipes, updates, restarts, plugins and crash alerts for your Rust servers, from one dashboard. One install command on your Linux host, no inbound ports for the agent.

Free during the open beta. Pricing will be announced before the beta ends.