feat(nixos): add SteamCMD server support

This commit is contained in:
2026-09-09 21:25:03 -04:00
parent b7ce5deb4c
commit 863dce5048
3 changed files with 457 additions and 226 deletions

View File

@@ -66,6 +66,45 @@
registry.address.pikachu.self = "10.23.10.102"; registry.address.pikachu.self = "10.23.10.102";
modules.services.steamcmd = {
enable = true;
dataDir = "/pikachu/data/steamcmd";
apps.valheim = {
enable = true;
appId = 896660;
installDir = "/pikachu/data/servers/valheim/main";
workingDirectory = "/pikachu/data/servers/valheim/main";
dataDirs = [ "/pikachu/media/games/servers/valheim/main" ];
packages = [ pkgs.steam-run ];
environment = {
SteamAppId = "892970";
SERVER_PASSWORD = "foobarbaz";
};
command = [
"${pkgs.bash}/bin/bash"
"-c"
''
${pkgs.coreutils}/bin/install -d -m 0770 /pikachu/media/games/servers/valheim/main
export LD_LIBRARY_PATH="$PWD/linux64:$LD_LIBRARY_PATH"
exec steam-run ./valheim_server.x86_64 \
-nographics \
-batchmode \
-name main \
-port 2456 \
-world main \
-savedir /pikachu/media/games/servers/valheim/main \
-password "$SERVER_PASSWORD" \
-public 0
''
];
udpPorts = [
2456
2457
2458
];
};
};
guests.actualbudget = { guests.actualbudget = {
enable = true; enable = true;
vlan = 20; vlan = 20;

View File

@@ -0,0 +1,325 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.modules.services.steamcmd;
appType = lib.types.submodule (
{ config, name, ... }:
{
options = {
enable = lib.mkEnableOption "this SteamCMD app service";
appId = lib.mkOption {
type = lib.types.ints.positive;
example = 896660;
description = "Steam app ID for the dedicated server.";
};
installDir = lib.mkOption {
type = lib.types.str;
example = "/data/servers/valheim/main";
description = "Directory where SteamCMD installs the server files.";
};
workingDirectory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/data/servers/valheim/main";
description = "Directory where the game service starts.";
};
command = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [
"/data/servers/valheim/main/valheim_server.x86_64"
"-name"
"main"
];
description = "Command that starts the game server.";
};
user = lib.mkOption {
type = lib.types.str;
default = cfg.user;
defaultText = lib.literalExpression "config.modules.services.steamcmd.user";
description = "User account that runs the game service.";
};
group = lib.mkOption {
type = lib.types.str;
default = cfg.group;
defaultText = lib.literalExpression "config.modules.services.steamcmd.group";
description = "Group account that owns writable server files.";
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Environment variables for the game service.";
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
description = "Environment files loaded by the game service.";
};
dataDirs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "/media/games/servers/valheim/main" ];
description = "Additional writable directories created before the game service starts.";
};
updateOnStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Runs SteamCMD before each game service start.";
};
validateUpdate = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Passes validate to SteamCMD app_update.";
};
packages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
description = "Packages added to the game service path.";
};
tcpPorts = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
description = "TCP ports opened for the game service.";
};
udpPorts = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
description = "UDP ports opened for the game service.";
};
restart = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enables the scheduled restart timer.";
};
onCalendar = lib.mkOption {
type = lib.types.str;
default = "daily";
example = "*-*-* 05:00:00";
description = "Calendar expression for the restart timer.";
};
randomizedDelaySec = lib.mkOption {
type = lib.types.str;
default = "15m";
description = "Maximum randomized delay for the restart timer.";
};
command = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "systemctl restart valheim.service";
description = "Command run by the restart timer.";
};
};
};
config = {
workingDirectory = lib.mkDefault config.installDir;
};
}
);
enabledApps = lib.filterAttrs (_: app: app.enable) cfg.apps;
prepareScript = name: app:
pkgs.writeShellScript "steamcmd-${name}-prepare" ''
set -eu
${pkgs.coreutils}/bin/install -d \
-o ${lib.escapeShellArg cfg.user} \
-g ${lib.escapeShellArg cfg.group} \
-m 0770 \
${lib.escapeShellArg cfg.dataDir}
${pkgs.coreutils}/bin/install -d \
-o ${lib.escapeShellArg app.user} \
-g ${lib.escapeShellArg app.group} \
-m 0770 \
${lib.escapeShellArg app.installDir} \
${lib.escapeShellArgs app.dataDirs}
'';
updateScript = name: app:
pkgs.writeShellScript "steamcmd-${name}-update" ''
set -eu
export HOME=${lib.escapeShellArg cfg.dataDir}
exec ${lib.getExe cfg.package} \
+force_install_dir ${lib.escapeShellArg app.installDir} \
+login anonymous \
+app_update ${toString app.appId} ${lib.optionalString app.validateUpdate "validate"} \
+quit
'';
startScript = name: app:
pkgs.writeShellScript "steamcmd-${name}-start" ''
set -eu
cd ${lib.escapeShellArg app.workingDirectory}
exec ${lib.escapeShellArgs app.command}
'';
restartCommand = name: app:
if app.restart.command != null then
app.restart.command
else
"systemctl restart ${lib.escapeShellArg name}.service";
in
{
options.modules.services.steamcmd = {
enable = lib.mkEnableOption "SteamCMD service management";
package = lib.mkPackageOption pkgs "steamcmd" { };
user = lib.mkOption {
type = lib.types.str;
default = "steamcmd";
description = "User account that runs SteamCMD.";
};
group = lib.mkOption {
type = lib.types.str;
default = "steamcmd";
description = "Group account that owns SteamCMD files.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/data/steamcmd";
description = "Directory where SteamCMD stores metadata and cache files.";
};
apps = lib.mkOption {
type = lib.types.attrsOf appType;
default = { };
description = "SteamCMD-managed game services keyed by service name.";
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.steamcmd = 20003;
}
(lib.mkIf cfg.enable {
assertions = [
{
assertion = enabledApps != { };
message = "modules.services.steamcmd.apps must contain at least one enabled app.";
}
];
users.groups.${cfg.group} = { };
users.users.${cfg.user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = cfg.group;
home = cfg.dataDir;
};
systemd.tmpfiles.rules =
[
"d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -"
"z ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -"
]
++ lib.flatten (
lib.mapAttrsToList (_: app: [
"d ${app.installDir} 0770 ${cfg.user} ${cfg.group} - -"
"z ${app.installDir} 0770 ${cfg.user} ${cfg.group} - -"
]) enabledApps
);
systemd.services = lib.mkMerge (
lib.mapAttrsToList (name: app: {
"steamcmd-${name}-update" = {
description = "Update ${name} with SteamCMD";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = app.user;
Group = app.group;
WorkingDirectory = "-${cfg.dataDir}";
ExecStartPre = "+${prepareScript name app}";
ExecStart = updateScript name app;
};
};
${name} = {
description = "${name} game server";
after = [
"network-online.target"
"steamcmd-${name}-update.service"
];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = app.packages;
environment = app.environment;
serviceConfig = {
Type = "simple";
User = app.user;
Group = app.group;
WorkingDirectory = "-${app.workingDirectory}";
EnvironmentFile = app.environmentFiles;
ExecStartPre = [
"+${prepareScript name app}"
] ++ lib.optional app.updateOnStart (updateScript name app);
ExecStart = startScript name app;
Restart = "on-failure";
};
};
"steamcmd-${name}-restart" = lib.mkIf app.restart.enable {
description = "Restart ${name}";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "steamcmd-${name}-restart" ''
set -eu
exec ${restartCommand name app}
'';
};
};
}) enabledApps
);
systemd.timers = lib.mkMerge (
lib.mapAttrsToList (name: app: {
"steamcmd-${name}-restart" = lib.mkIf app.restart.enable {
description = "Restart ${name} on a schedule";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = app.restart.onCalendar;
RandomizedDelaySec = app.restart.randomizedDelaySec;
Persistent = true;
};
};
}) enabledApps
);
networking.firewall.allowedTCPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.tcpPorts) enabledApps);
networking.firewall.allowedUDPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.udpPorts) enabledApps);
})
];
}

View File

@@ -1,226 +1,93 @@
# SteamCMD server notes # Add a SteamCMD server
These notes describe the emerging pattern for SteamCMD-based game servers in this monorepo. ## 1. Configure the generic SteamCMD module
They come from the Valheim prototype on Pikachu.
1. Create or update `configs/nixos/modules/services/steamcmd.nix`.
## Goal 2. Define `modules.services.steamcmd.enable`.
3. Create the `steamcmd` service user.
Use NixOS to manage server operations, not game content. 4. Configure the shared SteamCMD data directory.
5. Add one option for each Steam app service.
Nix owns service wiring, launch flags, ports, timers, users, and mounts. 6. Run SteamCMD before each app service starts.
SteamCMD owns downloaded server files. 7. Update the Steam app on each service restart.
The game owns world data, generated config, saves, and modded files. 8. Add a restart timer with default settings.
9. Add an option that overrides the restart command.
## Layering 10. Open declared in-machine firewall ports.
Use three layers for SteamCMD-based servers. ## 2. Configure the game service module
```text 1. Create or update `configs/nixos/modules/services/<game>.nix`.
configs/nixos/modules/services/steamcmd.nix 2. Define `modules.services.<game>.enable`.
configs/nixos/modules/services/<game>.nix 3. Add the Steam app ID for the dedicated server.
configs/nixos/guests/<game>.nix 4. Add runtime packages that the server binary needs.
``` 5. Add `serverName` for the service identity.
6. Use `serverName` as the default world or save name.
### `modules.services.steamcmd` 7. Add a game-specific world or save option when the game supports it.
8. Add guest-internal paths for server files and durable state.
This module provides the generic SteamCMD machinery. 9. Add options for required ports.
10. Add options for required secret files or values.
It runs on any NixOS system. 11. Map the game options to `modules.services.steamcmd`.
That system can be a physical host or a guest. 12. Configure the game service command.
13. Pass secrets to the service without embedding secret values in Nix.
It owns: 14. Open the required in-machine firewall ports.
- the `steamcmd` service user, ## 3. Configure the guest module
- the shared SteamCMD data directory,
- per-instance Steam app updates, 1. Create or update `configs/nixos/guests/<game>.nix`.
- per-instance game service units, 2. Define `guests.<game>` options for host-level wiring.
- optional hard restart timers, 3. Add `serverName`.
- firewall openings for declared ports. 4. Pass `serverName` to `modules.services.<game>` inside `interior`.
5. Add host path options for SteamCMD data, server files, and durable state.
It does not know Valheim, PalWorld, or Project Zomboid semantics. 6. Use names such as `steamcmdPath`, `serverPath`, and `statePath`.
Game modules translate game-specific settings into SteamCMD instances. 7. Mount each host path into the guest.
8. Mount sops-backed secret files read-only into the guest.
### `modules.services.<game>` 9. Pass guest paths for secret files to `modules.services.<game>`.
10. Set guest resource limits.
A game service module wraps `modules.services.steamcmd`. 11. Keep VLAN placement and address wiring in the guest or host configuration.
For Valheim, this layer owns: ## 4. Add host storage
- Steam app id `896660`, 1. Choose a host path for SteamCMD data.
- the Valheim runtime app id, 2. Choose a host path for downloaded server files.
- launch flags such as `-name`, `-world`, and `-port`, 3. Choose a host path for world data, saves, and generated config.
- the expected UDP port range, 4. Configure those paths through `guests.<game>`.
- the mapping from instance options to a SteamCMD instance.
## 5. Add secrets
This layer exposes operational config only.
It avoids owning mutable gameplay files unless the game exposes stable launch flags. 1. Add a sops secret for each durable server secret.
2. Configure the guest option that names each sops secret.
### `guests.<game>` 3. Do not put passwords or tokens in Nix strings.
4. Do not pass passwords or tokens as process arguments.
A guest module turns a game service into a deployable guest.
## 6. Configure networking
It owns host-facing concerns:
1. Keep game port knowledge in `modules.services.<game>`.
- VLAN placement, 2. Open game ports from `modules.services.<game>`.
- guest address registry entry, 3. Configure guest VLAN placement from the guest or host configuration.
- host paths and bind mounts, 4. Add a DHCP reservation before you commit a stable guest address.
- resource limits, 5. Forward public ports to the guest address when the server needs public access.
- secret file mounts,
- game-family instance declarations. ## 7. Enable the guest on the target host
Do not add a reusable `guests.steamcmd` base guest. 1. Set `guests.<game>.enable = true`.
The guest auto-loader turns `configs/nixos/guests/steamcmd.nix` into a concrete guest. 2. Set `guests.<game>.serverName` to the server identity.
A specialized guest cannot use it as a base without creating another container. 3. Set the host paths for SteamCMD data, server files, and durable state.
4. Set the secret names for the server.
## Instance model 5. Set the guest network options required by the host.
Use one guest per game family. ## 8. Deploy the host
Use named instances inside that guest.
1. Run `deploy-to <host>` from a clean checkout.
For example, `guests.palworld` can contain `instances.current` and `instances.old`. 2. Check `container@<game>.service` on the host.
Each instance has its own ports and paths. 3. Enter the guest with `nixos-container root-login <game>`.
The host config must avoid clashes. 4. Check the SteamCMD update service.
5. Check the game service with `systemctl status <unit>`.
For Valheim, the prototype uses `instances.foo`. 6. Follow logs with `journalctl -u <unit> -f`.
The instance name is the operational identity.
The world name defaults to the instance name. ## 9. Validate the server
Keep these names separate in the module interface: 1. Confirm the guest address with `machinectl status <game>`.
2. Connect from the LAN before you add public forwarding.
```text 3. Restart the game service.
instance name -> unit names and paths 4. Confirm that the restart updates the Steam app.
worldName -> game launch flag 5. Confirm that the restart timer uses the expected command.
``` 6. Confirm that durable state lands in the mounted state path.
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.