--- title: "How to Auto-Start HLDS with a systemd Service" excerpt: "Write a proper systemd unit for HLDS so it starts on boot, restarts on crash, runs as an unprivileged user, and streams its console to journalctl." category: "installation" tags: "systemd, autostart" image: "" read_time: "8 min read" ---
Starting a server by hand in an SSH session works right up until the session closes or the box reboots. A systemd unit fixes that properly: the server starts on boot, restarts if it crashes, runs as an unprivileged user, and its console goes to the journal where you can actually read it. This is the setup that turns a test server into something you can leave running.
There is one gotcha unique to HLDS. The usual launcher, hlds_run, is a shell wrapper that already restarts hlds_linux after a crash. If systemd also restarts on failure, the two can fight — systemd sees the wrapper still alive and never triggers, or you get confusing double restarts. Pick one:
-norestart to hlds_run so it exits on crash and systemd brings it back. This is the approach below.Restart=no and rely on the wrapper. You then lose systemd's crash accounting.Create /etc/systemd/system/hlds.service:
[Unit] Description=CS 1.6 Dedicated Server After=network-online.target Wants=network-online.target [Service] Type=simple User=steam Group=steam WorkingDirectory=/home/steam/hlds ExecStart=/home/steam/hlds/hlds_run -game cstrike -norestart \ -strictportbind -ip 0.0.0.0 -port 27015 \ +map de_dust2 +maxplayers 20 +sv_lan 0 Restart=on-failure RestartSec=5 KillSignal=SIGINT [Install] WantedBy=multi-user.target
Points that matter:
User=steam — never run the engine as root. If the unit is under /etc/systemd/system it runs as root unless you set the user.WorkingDirectory — HLDS resolves cstrike/ relative to the working directory. Get this wrong and it dies with a missing-liblist.gam style error.-norestart — hands crash-restart to systemd (see step 1).KillSignal=SIGINT — lets HLDS shut down cleanly on systemctl stop instead of being hard-killed.network-online.target — the server needs the network up before it heartbeats the master.sudo systemctl daemon-reload sudo systemctl enable --now hlds sudo systemctl status hlds
enable makes it start on boot; --now starts it immediately. status should show active (running) and the most recent console lines.
Because the unit is Type=simple, everything HLDS prints goes to the journal:
journalctl -u hlds -f
That is your live console. To see the last boot's output:
journalctl -u hlds -n 100 --no-pager
systemd captures stdout but does not give you an interactive stdin. To type commands into a running server you have two clean options: use RCON from a client or web tool, or run the server inside a tmux/screen session that systemd launches. If you want console access, wrap the start in tmux:
ExecStart=/usr/bin/tmux new-session -d -s cs16 \ '/home/steam/hlds/hlds_run -game cstrike -norestart -port 27015 +map de_dust2 +maxplayers 20 +sv_lan 0' ExecStop=/usr/bin/tmux kill-session -t cs16 Type=forking RemainAfterExit=yes
Then attach with tmux attach -t cs16. For most people, RCON is enough and the plain unit above is better because systemd tracks the process directly.
Running several instances? Use a template unit hlds@.service parameterised by directory, as covered in running multiple servers on one VPS, and enable hlds@server1, hlds@server2, and so on.
User=steam. Add it; a network binary running as root is a real risk.status=203/EXEC or "No such file or directory" — the ExecStart path is wrong, or hlds_run is not executable. Use absolute paths and chmod +x.liblist.gam — WorkingDirectory is wrong. It must be the server root that contains hlds_run and cstrike/.hlds_run are restarting. Add -norestart to the command.After=network-online.target and Wants=network-online.target.systemctl stop takes 90s then kills it — add KillSignal=SIGINT so HLDS shuts down cleanly instead of timing out.Because systemd manages the process directly, you can sandbox it further at no cost. A few directives worth adding to the [Service] block on a public server:
NoNewPrivileges=true PrivateTmp=true ProtectSystem=full ProtectHome=read-only
ProtectHome=read-only is too strict if the server writes logs and bans under /home/steam — in that case drop it or switch to ReadWritePaths=/home/steam/hlds. Test each directive one at a time: an over-tight sandbox that blocks a path the engine needs shows up as a startup failure in journalctl, not as an obvious permission message.
sudo systemctl restart hlds sudo systemctl status hlds ss -lunp | grep 27015
Status should read active (running) and the port should be bound by hlds_linux. Now reboot the box and confirm the server comes back on its own:
sudo reboot # after it comes back: systemctl is-active hlds
An active result after a cold boot means autostart works. Combine this with an annotated server.cfg and the correct firewall ports and the server is production-ready.