refactor(nixos): make SteamCMD service single-instance

This commit is contained in:
2026-09-09 21:43:37 -04:00
parent 88f082f1c0
commit 266b90e488
3 changed files with 242 additions and 292 deletions

View File

@@ -69,40 +69,38 @@
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";
group = "storage";
dataDirs = [ "/pikachu/media/games/servers/valheim/main" ];
packages = [ pkgs.steam-run ];
environment = {
SteamAppId = "892970";
SERVER_PASSWORD = "foobarbaz";
};
command = [
"${pkgs.bash}/bin/bash"
"-c"
''
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
];
serviceName = "valheim";
appId = 896660;
installDir = "/pikachu/data/servers/valheim/main";
workingDirectory = "/pikachu/data/servers/valheim/main";
group = "storage";
dataDirs = [ "/pikachu/media/games/servers/valheim/main" ];
packages = [ pkgs.steam-run ];
environment = {
SteamAppId = "892970";
SERVER_PASSWORD = "foobarbaz";
};
command = [
"${pkgs.bash}/bin/bash"
"-c"
''
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 = {

View File

@@ -7,201 +7,70 @@
let
cfg = config.modules.services.steamcmd;
appType = lib.types.submodule (
{ config, name, ... }:
{
options = {
enable = lib.mkEnableOption "this SteamCMD app service";
prepareScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-prepare" ''
set -eu
appId = lib.mkOption {
type = lib.types.ints.positive;
example = 896660;
description = "Steam app ID for the dedicated server.";
};
${pkgs.coreutils}/bin/install -d \
-o ${lib.escapeShellArg cfg.user} \
-g ${lib.escapeShellArg cfg.group} \
-m 0770 \
${lib.escapeShellArg cfg.dataDir}
installDir = lib.mkOption {
type = lib.types.str;
example = "/data/servers/valheim/main";
description = "Directory where SteamCMD installs the server files.";
};
${pkgs.coreutils}/bin/install -d \
-o ${lib.escapeShellArg cfg.user} \
-g ${lib.escapeShellArg cfg.group} \
-m 0770 \
${lib.escapeShellArg cfg.installDir} \
${lib.escapeShellArgs cfg.dataDirs}
'';
workingDirectory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/data/servers/valheim/main";
description = "Directory where the game service starts.";
};
updateScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-update" ''
set -eu
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.";
};
export HOME=${lib.escapeShellArg cfg.dataDir}
exec ${lib.getExe cfg.package} \
+force_install_dir ${lib.escapeShellArg cfg.installDir} \
+login anonymous \
+app_update ${toString cfg.appId} ${lib.optionalString cfg.validateUpdate "validate"} \
+quit
'';
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.";
};
startScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-start" ''
set -eu
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.";
};
cd ${lib.escapeShellArg cfg.workingDirectory}
exec ${lib.escapeShellArgs cfg.command}
'';
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
restartCommand =
if cfg.restart.command != null then
cfg.restart.command
else
"systemctl restart ${lib.escapeShellArg name}.service";
"systemctl restart ${lib.escapeShellArg cfg.serviceName}.service";
in
{
options.modules.services.steamcmd = {
enable = lib.mkEnableOption "SteamCMD service management";
enable = lib.mkEnableOption "SteamCMD-managed game server";
package = lib.mkPackageOption pkgs "steamcmd" { };
serviceName = lib.mkOption {
type = lib.types.str;
default = "steamcmd";
example = "valheim";
description = "Name of the systemd service that runs the game server.";
};
user = lib.mkOption {
type = lib.types.str;
default = "steamcmd";
description = "User account that runs SteamCMD.";
description = "User account that runs SteamCMD and the game server.";
};
group = lib.mkOption {
type = lib.types.str;
default = "steamcmd";
description = "Group account that owns SteamCMD files.";
description = "Group account that owns writable server files.";
};
dataDir = lib.mkOption {
@@ -210,10 +79,111 @@ in
description = "Directory where SteamCMD stores metadata and cache files.";
};
apps = lib.mkOption {
type = lib.types.attrsOf appType;
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.str;
default = cfg.installDir;
defaultText = lib.literalExpression "config.modules.services.steamcmd.installDir";
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.";
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "SteamCMD-managed game services keyed by service name.";
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.";
};
};
};
@@ -223,13 +193,6 @@ in
}
(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;
@@ -238,89 +201,78 @@ in
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 ${app.user} ${app.group} - -"
"z ${app.installDir} 0770 ${app.user} ${app.group} - -"
] ++ map (dataDir: "d ${dataDir} 0770 ${app.user} ${app.group} - -") app.dataDirs
++ map (dataDir: "z ${dataDir} 0770 ${app.user} ${app.group} - -") app.dataDirs) enabledApps
);
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -"
"z ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.installDir} 0770 ${cfg.user} ${cfg.group} - -"
"z ${cfg.installDir} 0770 ${cfg.user} ${cfg.group} - -"
] ++ map (dataDir: "d ${dataDir} 0770 ${cfg.user} ${cfg.group} - -") cfg.dataDirs
++ map (dataDir: "z ${dataDir} 0770 ${cfg.user} ${cfg.group} - -") cfg.dataDirs;
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;
};
systemd.services = {
"steamcmd-${cfg.serviceName}-update" = {
description = "Update ${cfg.serviceName} with SteamCMD";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "-${cfg.dataDir}";
ExecStartPre = "+${prepareScript}";
ExecStart = updateScript;
};
};
${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";
};
${cfg.serviceName} = {
description = "${cfg.serviceName} game server";
after = [
"network-online.target"
"steamcmd-${cfg.serviceName}-update.service"
];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = cfg.packages;
environment = cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "-${cfg.workingDirectory}";
EnvironmentFile = cfg.environmentFiles;
ExecStartPre = [
"+${prepareScript}"
] ++ lib.optional cfg.updateOnStart updateScript;
ExecStart = startScript;
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
"steamcmd-${cfg.serviceName}-restart" = lib.mkIf cfg.restart.enable {
description = "Restart ${cfg.serviceName}";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-restart" ''
set -eu
exec ${restartCommand name app}
'';
};
exec ${restartCommand}
'';
};
}) 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
);
systemd.timers."steamcmd-${cfg.serviceName}-restart" = lib.mkIf cfg.restart.enable {
description = "Restart ${cfg.serviceName} on a schedule";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.restart.onCalendar;
RandomizedDelaySec = cfg.restart.randomizedDelaySec;
Persistent = true;
};
};
networking.firewall.allowedTCPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.tcpPorts) enabledApps);
networking.firewall.allowedUDPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.udpPorts) enabledApps);
networking.firewall.allowedTCPPorts = cfg.tcpPorts;
networking.firewall.allowedUDPPorts = cfg.udpPorts;
})
];
}

View File

@@ -6,8 +6,8 @@
2. Define `modules.services.steamcmd.enable`.
3. Create the `steamcmd` service user.
4. Configure the shared SteamCMD data directory.
5. Add one option for each Steam app service.
6. Run SteamCMD before each app service starts.
5. Add options for one Steam app service.
6. Run SteamCMD before the game service starts.
7. Update the Steam app on each service restart.
8. Add a restart timer with default settings.
9. Add an option that overrides the restart command.