feat(nixos): add SteamCMD server support

This commit is contained in:
2026-09-10 07:58:02 -04:00
parent b7ce5deb4c
commit 2236a0d74c
3 changed files with 196 additions and 226 deletions

View File

@@ -66,6 +66,37 @@
registry.address.pikachu.self = "10.23.10.102"; registry.address.pikachu.self = "10.23.10.102";
modules.services.steamcmd = {
enable = true;
steamDir = "/pikachu/data/steamcmd";
serviceName = "valheim";
appId = 896660;
installDir = "/pikachu/data/servers/valheim/main";
stateDir = "/pikachu/media/games/servers/valheim/main";
executable = "./valheim_server.x86_64";
arguments = [
"-nographics"
"-batchmode"
"-name"
"main"
"-port"
"2456"
"-world"
"main"
"-savedir"
config.modules.services.steamcmd.stateDir
"-password"
"foobarbaz"
"-public"
"0"
];
udpPorts = [
2456
2457
2458
];
};
guests.actualbudget = { guests.actualbudget = {
enable = true; enable = true;
vlan = 20; vlan = 20;

View File

@@ -0,0 +1,165 @@
{
config,
lib,
pkgs,
...
}:
{
options.modules.services.steamcmd = {
enable = lib.mkEnableOption "SteamCMD-managed game server";
steamDir = lib.mkOption {
type = lib.types.str;
example = "/data/steamcmd";
description = "Directory where SteamCMD stores metadata and cache files.";
};
serviceName = lib.mkOption {
type = lib.types.str;
example = "valheim";
description = "Name of the systemd service that runs the game server.";
};
appId = lib.mkOption {
type = lib.types.ints.positive;
example = 896660;
description = "Steam app ID for the dedicated server package.";
};
installDir = lib.mkOption {
type = lib.types.str;
example = "/data/servers/valheim/main";
description = "Directory where SteamCMD installs the server files and where the game service starts.";
};
stateDir = lib.mkOption {
type = lib.types.str;
example = "/data/state/valheim/main";
description = "Writable directory for durable game server state.";
};
executable = lib.mkOption {
type = lib.types.str;
example = "./valheim_server.x86_64";
description = "Server executable, relative to installDir or absolute.";
};
arguments = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"-name"
"main"
];
description = "Arguments passed to the server executable.";
};
udpPorts = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
description = "UDP ports opened for the game service.";
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.steamcmd = 20003;
}
(lib.mkIf config.modules.services.steamcmd.enable (
let
service = config.modules.services.steamcmd;
user = "steamcmd";
group = "storage";
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
set -eu
${pkgs.coreutils}/bin/install -d \
-o ${user} \
-g ${group} \
-m 0770 \
${lib.escapeShellArg service.steamDir} \
${lib.escapeShellArg service.installDir} \
${lib.escapeShellArg service.stateDir}
'';
updateScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-update" ''
set -eu
export HOME=${lib.escapeShellArg service.steamDir}
exec ${lib.getExe pkgs.steamcmd} \
+force_install_dir ${lib.escapeShellArg service.installDir} \
+login anonymous \
+app_update ${toString service.appId} validate \
+quit
'';
startScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-start" ''
set -eu
cd ${lib.escapeShellArg service.installDir}
export LD_LIBRARY_PATH=linux64:''${LD_LIBRARY_PATH-}
exec ${lib.getExe pkgs.steam-run} ${lib.escapeShellArg service.executable} ${lib.escapeShellArgs service.arguments}
'';
in
{
users.users.${user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
inherit group;
home = service.steamDir;
};
systemd.tmpfiles.rules = [
"d ${service.steamDir} 0770 ${user} ${group} - -"
"z ${service.steamDir} 0770 ${user} ${group} - -"
"d ${service.installDir} 0770 ${user} ${group} - -"
"z ${service.installDir} 0770 ${user} ${group} - -"
"d ${service.stateDir} 0770 ${user} ${group} - -"
"z ${service.stateDir} 0770 ${user} ${group} - -"
];
systemd.services = {
"steamcmd-${service.serviceName}-update" = {
description = "Update ${service.serviceName} with SteamCMD";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = user;
Group = group;
WorkingDirectory = "-${service.steamDir}";
ExecStartPre = "+${prepareScript}";
ExecStart = updateScript;
};
};
${service.serviceName} = {
description = "${service.serviceName} game server";
after = [
"network-online.target"
"steamcmd-${service.serviceName}-update.service"
];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = user;
Group = group;
WorkingDirectory = "-${service.installDir}";
ExecStartPre = [
"+${prepareScript}"
updateScript
];
ExecStart = startScript;
Restart = "on-failure";
};
};
};
networking.firewall.allowedUDPPorts = service.udpPorts;
}
))
];
}

View File

@@ -1,226 +0,0 @@
# SteamCMD server notes
These notes describe the emerging pattern for SteamCMD-based game servers in this monorepo.
They come from the Valheim prototype on Pikachu.
## Goal
Use NixOS to manage server operations, not game content.
Nix owns service wiring, launch flags, ports, timers, users, and mounts.
SteamCMD owns downloaded server files.
The game owns world data, generated config, saves, and modded files.
## Layering
Use three layers for SteamCMD-based servers.
```text
configs/nixos/modules/services/steamcmd.nix
configs/nixos/modules/services/<game>.nix
configs/nixos/guests/<game>.nix
```
### `modules.services.steamcmd`
This module provides the generic SteamCMD machinery.
It runs on any NixOS system.
That system can be a physical host or a guest.
It owns:
- the `steamcmd` service user,
- the shared SteamCMD data directory,
- per-instance Steam app updates,
- per-instance game service units,
- optional hard restart timers,
- firewall openings for declared ports.
It does not know Valheim, PalWorld, or Project Zomboid semantics.
Game modules translate game-specific settings into SteamCMD instances.
### `modules.services.<game>`
A game service module wraps `modules.services.steamcmd`.
For Valheim, this layer owns:
- Steam app id `896660`,
- the Valheim runtime app id,
- launch flags such as `-name`, `-world`, and `-port`,
- the expected UDP port range,
- the mapping from instance options to a SteamCMD instance.
This layer exposes operational config only.
It avoids owning mutable gameplay files unless the game exposes stable launch flags.
### `guests.<game>`
A guest module turns a game service into a deployable guest.
It owns host-facing concerns:
- VLAN placement,
- guest address registry entry,
- host paths and bind mounts,
- resource limits,
- secret file mounts,
- game-family instance declarations.
Do not add a reusable `guests.steamcmd` base guest.
The guest auto-loader turns `configs/nixos/guests/steamcmd.nix` into a concrete guest.
A specialized guest cannot use it as a base without creating another container.
## Instance model
Use one guest per game family.
Use named instances inside that guest.
For example, `guests.palworld` can contain `instances.current` and `instances.old`.
Each instance has its own ports and paths.
The host config must avoid clashes.
For Valheim, the prototype uses `instances.foo`.
The instance name is the operational identity.
The world name defaults to the instance name.
Keep these names separate in the module interface:
```text
instance name -> unit names and paths
worldName -> game launch flag
```
This permits a test instance to load a copied world later.
## Storage layout
Use three storage roots.
```text
/data/steamcmd
/data/servers/<game>/<instance-name>
/media/games/servers/<game>/<instance-name>
```
### `/data/steamcmd`
Store SteamCMD data and cache here.
This directory contains mutable Steam metadata and download state.
Do not treat it as durable game state.
### `/data/servers/<game>/<instance-name>`
Store the installed dedicated server files here.
SteamCMD writes these files.
Mods can also mutate this tree.
For that reason, use per-instance app installs.
Do not share one app install between modded or historical instances.
### `/media/games/servers/<game>/<instance-name>`
Store world data, saves, generated config, and durable game state here.
Backups are out of scope for the first prototype.
This path gives a stable backup boundary later.
## Updates
Do not pin game server versions in Nix.
Steam controls the server build.
Nix controls when updates run.
The prototype updates on service start.
A daily hard restart timer can pull updates during a maintenance window.
The generic SteamCMD layer supports a restart timer.
The default timer command restarts the instance service.
Game modules or host config can override that command later.
Avoid generic player-warning or player-count logic in the SteamCMD layer.
Those flows are game-specific.
## Secrets
Use sops-backed environment files for durable configuration.
Mount them read-only into the guest.
The Valheim prototype temporarily supports a hardcoded password.
That password leaks into the Nix store and process arguments.
Use it only for throwaway tests.
The target model is:
```text
host sops secret -> read-only guest mount -> systemd EnvironmentFile
```
For Valheim, the environment file contains:
```sh
SERVER_PASSWORD=example-password
```
## Networking
Open ports in the guest firewall from the game module.
Valheim uses the base UDP port through `basePort + 2`.
The default base port is `2456`, so open UDP `2456-2458`.
For public access, forward those UDP ports to the guest address.
DNS is optional for the first deployment.
Use `machinectl status <guest>` to confirm the current DHCP address.
Do not trust a guessed address.
Add a DHCP reservation before depending on a committed address.
## Valheim prototype
The prototype proved these facts:
- Valheim runs inside a NixOS guest on Pikachu.
- SteamCMD installs the dedicated server files at service start.
- `steam-run` runs the mutable Valheim server binary.
- LAN clients can connect to the guest address and UDP port.
- The storage split works for SteamCMD data, server files, and world data.
The prototype used:
```text
guest: valheim
instance: foo
password: foobarbaz
actual DHCP address during test: 10.23.20.197
```
The hardcoded password and test instance name need cleanup before a final PR.
## Bring-up checklist
1. Add or update `modules.services.<game>`.
2. Map game-specific options to `modules.services.steamcmd.instances`.
3. Add or update `guests.<game>`.
4. Mount `/data/steamcmd` into the guest.
5. Mount `/data/servers/<game>/<instance-name>` into the guest.
6. Mount `/media/games/servers/<game>/<instance-name>` into the guest.
7. Add a sops environment file for secrets.
8. Open the required game ports in the guest firewall.
9. Enable the guest on the target host.
10. Deploy with `deploy-to <host>`.
11. Check `container@<game>.service` on the host.
12. Enter the guest with `nixos-container root-login <game>`.
13. Check the game service with `systemctl status`.
14. Follow logs with `journalctl -u <unit> -f`.
15. Connect from LAN before adding public forwarding.
## Open cleanup items
- Replace hardcoded prototype passwords with sops environment files.
- Choose durable instance names before long-term use.
- Add DHCP reservations for game guests.
- Add backup policy for world and modded server data.
- Decide whether server install trees need backup for modded servers.