feat(nixos): add Palworld Tracion guest #39

Merged
alexion merged 1 commits from feat/palworld-tracion into main 2026-09-10 14:37:59 -04:00
9 changed files with 525 additions and 17 deletions

View File

@@ -0,0 +1,146 @@
{
config,
lib,
...
}:
let
cfg = config.guests.palworld;
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";
in
{
options.guests.palworld = {
steamPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/steamcmd";
description = "Host path bind-mounted as SteamCMD's metadata directory.";
};
installPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/palworld/server";
description = "Host path bind-mounted as Palworld's server install directory.";
};
statePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/palworld/state";
description = "Host path bind-mounted as Palworld's durable state directory.";
};
serverName = lib.mkOption {
type = lib.types.str;
default = "palworld";
description = "Name shown for the Palworld server.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8211;
description = "UDP port for the Palworld server.";
};
publicIP = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Public IP address advertised by the Palworld server.";
};
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/game-servers/password";
description = "SOPS secret name containing the Palworld server password.";
};
adminPasswordSecret = lib.mkOption {
type = lib.types.str;
default = "services/game-servers/password";
description = "SOPS secret name containing the Palworld admin password.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.steamPath != null;
message = "guests.palworld.steamPath must point at the host directory that stores SteamCMD metadata.";
}
{
assertion = cfg.installPath != null;
message = "guests.palworld.installPath must point at the host directory that stores Palworld server files.";
}
{
assertion = cfg.statePath != null;
message = "guests.palworld.statePath must point at the host directory that stores Palworld durable state.";
}
];
guests.palworld = {
interior.modules.services.games.palworld = {
enable = true;
inherit (cfg) serverName port publicIP;
inherit steamDir installDir stateDir;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
adminPasswordFile = "/run/secrets/${cfg.adminPasswordSecret}";
};
mounts =
{
${steamDir} = {
directory = cfg.steamPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${installDir} = {
directory = cfg.installPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${"/run/secrets/${cfg.adminPasswordSecret}"} = {
file = config.sops.secrets.${cfg.adminPasswordSecret}.path;
readOnly = true;
};
};
limits = {
memory = lib.mkDefault "8G";
cpu = lib.mkDefault "400%";
tasksMax = lib.mkDefault 2048;
};
};
sops.secrets =
{
${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${cfg.adminPasswordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
};
}

View File

@@ -86,6 +86,25 @@
}; };
}; };
guests.palworld = {
enable = true;
vlan = 20;
address = "10.23.20.250";
steamPath = "/pikachu/data/steamcmd";
installPath = "/pikachu/data/servers/palworld/tracion";
statePath = "/pikachu/media/games/servers/palworld/tracion";
serverName = "Tracion";
adminPasswordSecret = "services/game-servers/admin/password";
publicIP = config.guests.palworld.address;
reverseProxy = {
enable = true;
route = {
domain = "palworld.alexion.dev";
udp.ports = [ config.guests.palworld.port ];
};
};
};
guests.actualbudget = { guests.actualbudget = {
enable = true; enable = true;
vlan = 20; vlan = 20;

View File

@@ -0,0 +1,148 @@
{
config,
lib,
pkgs,
...
}:
{
options.modules.services.games.palworld = {
enable = lib.mkEnableOption "Palworld dedicated server";
steamDir = lib.mkOption {
type = lib.types.str;
example = "/data/steamcmd";
description = "Directory where SteamCMD stores metadata and cache files.";
};
installDir = lib.mkOption {
type = lib.types.str;
example = "/data/servers/palworld/tracion";
description = "Directory where SteamCMD installs the Palworld server files.";
};
stateDir = lib.mkOption {
type = lib.types.str;
example = "/data/state/palworld/tracion";
description = "Directory where Palworld stores durable server state.";
};
serverName = lib.mkOption {
type = lib.types.str;
default = "palworld";
description = "Name shown for the Palworld server.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8211;
description = "UDP port for the Palworld server.";
};
publicIP = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Public IP address advertised by the Palworld server.";
};
maxPlayers = lib.mkOption {
type = lib.types.ints.between 1 32;
default = 32;
description = "Maximum player count for the Palworld server.";
};
publicLobby = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Advertise the server in Palworld's public lobby.";
};
rcon = {
enable = lib.mkEnableOption "Palworld RCON" // {
default = true;
};
port = lib.mkOption {
type = lib.types.port;
default = 25575;
description = "TCP port for Palworld RCON.";
};
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Palworld server password.";
};
adminPasswordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Palworld admin password.";
};
};
config = lib.mkIf config.modules.services.games.palworld.enable (
let
service = config.modules.services.games.palworld;
savedDir = "${service.installDir}/Pal/Saved";
startScript = pkgs.writeShellScript "palworld-start" ''
set -eu
${pkgs.coreutils}/bin/install -d \
-m 0770 \
${lib.escapeShellArg service.stateDir} \
${lib.escapeShellArg (builtins.dirOf savedDir)}
if [ -L ${lib.escapeShellArg savedDir} ]; then
current_target="$(${pkgs.coreutils}/bin/readlink ${lib.escapeShellArg savedDir})"
if [ "$current_target" != ${lib.escapeShellArg service.stateDir} ]; then
${pkgs.coreutils}/bin/rm ${lib.escapeShellArg savedDir}
fi
fi
if [ -d ${lib.escapeShellArg savedDir} ] && [ ! -L ${lib.escapeShellArg savedDir} ]; then
if ${pkgs.findutils}/bin/find ${lib.escapeShellArg service.stateDir} -mindepth 1 -maxdepth 1 | ${pkgs.gnugrep}/bin/grep -q .; then
${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir}
else
${pkgs.coreutils}/bin/cp -a ${lib.escapeShellArg savedDir}/. ${lib.escapeShellArg service.stateDir}/
${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir}
fi
fi
if [ ! -e ${lib.escapeShellArg savedDir} ]; then
${pkgs.coreutils}/bin/ln -s ${lib.escapeShellArg service.stateDir} ${lib.escapeShellArg savedDir}
fi
password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.passwordFile})"
admin_password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.adminPasswordFile})"
exec ./PalServer.sh \
"$@" \
"-serverpassword=$password" \
"-adminpassword=$admin_password"
'';
in
{
modules.services.steamcmd = {
enable = true;
inherit (service) steamDir installDir stateDir;
serviceName = "palworld-${lib.toLower service.serverName}";
appId = 2394010;
executable = "${startScript}";
arguments =
[
"-useperfthreads"
"-NoAsyncLoadingThread"
"-UseMultithreadForDS"
"-port=${toString service.port}"
"-publicport=${toString service.port}"
"-servername=${service.serverName}"
"-players=${toString service.maxPlayers}"
]
++ lib.optional service.publicLobby "-publiclobby"
++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ]
++ lib.optional service.rcon.enable "-rcon";
udpPorts = [ service.port ];
tcpPorts = [ service.port ];
};
}
);
}

View File

@@ -59,6 +59,12 @@
default = [ ]; default = [ ];
description = "UDP ports opened for the game service."; description = "UDP ports opened for the game service.";
}; };
tcpPorts = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
description = "TCP ports opened for the game service.";
};
}; };
config = lib.mkMerge [ config = lib.mkMerge [
@@ -159,6 +165,7 @@
}; };
networking.firewall.allowedUDPPorts = service.udpPorts; networking.firewall.allowedUDPPorts = service.udpPorts;
networking.firewall.allowedTCPPorts = service.tcpPorts;
} }
)) ))
]; ];

View File

@@ -18,13 +18,6 @@ or begin
return 2 return 2
end end
set untracked_lines (git status --porcelain --untracked-files=normal | string match -r '^\?\? ')
if test (count $untracked_lines) -gt 0
echo "deploy-to: refusing to deploy untracked files" >&2
echo "deploy-to: git add new files that Nix must see, or remove them" >&2
return 1
end
git fetch origin git fetch origin
or return $status or return $status

View File

@@ -6,6 +6,8 @@ services:
password: ENC[AES256_GCM,data:SSyo2l6tCjgjxIuCEREIxU+inw2WHtJJR0/IEKUqefFfuAdA5Nf+lH/jfPw=,iv:9225w1Yur3KL3dBr1ct1aPvDdVJ94O8q9qAUiRrj8Nc=,tag:qZgx4XieVq4BcxkTZc0KGQ==,type:str] password: ENC[AES256_GCM,data:SSyo2l6tCjgjxIuCEREIxU+inw2WHtJJR0/IEKUqefFfuAdA5Nf+lH/jfPw=,iv:9225w1Yur3KL3dBr1ct1aPvDdVJ94O8q9qAUiRrj8Nc=,tag:qZgx4XieVq4BcxkTZc0KGQ==,type:str]
game-servers: game-servers:
password: ENC[AES256_GCM,data:Wtq03MLRnc6/,iv:HhbiP8ZcltVGbCNDdP/gH5mCZXhVCTFv1DGzfrrgQG8=,tag:srsqcBQSD/HU8zFsuFimJQ==,type:str] password: ENC[AES256_GCM,data:Wtq03MLRnc6/,iv:HhbiP8ZcltVGbCNDdP/gH5mCZXhVCTFv1DGzfrrgQG8=,tag:srsqcBQSD/HU8zFsuFimJQ==,type:str]
admin:
password: ENC[AES256_GCM,data:52jwSCfV,iv:MQf1VOHnGBf2yF+9SXk04FniAjghSaZe2Gn/xvAXU/0=,tag:SnrJFlHwOaM6vsNLBc84nw==,type:str]
sops: sops:
age: age:
- enc: | - enc: |
@@ -44,7 +46,7 @@ sops:
28uWUpKzzgErq1aEbKNgh4cU5TH2RlAHjtzRC3ARUu9hlW6lnB4pSw== 28uWUpKzzgErq1aEbKNgh4cU5TH2RlAHjtzRC3ARUu9hlW6lnB4pSw==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
lastmodified: "2026-09-10T13:40:57Z" lastmodified: "2026-09-10T18:32:33Z"
mac: ENC[AES256_GCM,data:3nu+ho3TNDnGE55LIaHqJxlMs1hWVK2wwiJiBIDH0jop9Wo/AgaUXzwXY1MwMXJHhGrkcjhwWWJdEgxX88NNFTc59b4kLtRqE3bfNXoQ3y3giyDMFyVE9YCNd4jyl+pLhpVSZA/+TKEIBpdZRwaXHrYSaLtysg/OdzXing0l3SM=,iv:C/vZ6X1HikuBn+pWTlPIfCfg5mtW2i4P6XLwN1VwF6k=,tag:UQl10fueTwCtXuirP5+C/g==,type:str] mac: ENC[AES256_GCM,data:g3xIylSvyn5Z0JBA8siFmMrZxoM3a3U5c+f9qFmb3zXDLF03/feCUlvG2jjB7P2N9phIGSIEOPk1dzZDBMwv6D2hK6MppupV+MIe9diIt0gGARIOJNphrxLDxMHtwcWg6Z20D6tbPl0ECtLI0K9GOtwvI0OXeyYzu5VgFaFlZHw=,iv:TAUVJRXFTKNVA0ORMD7x56WVGjG+zimj3iqEn7urh64=,tag:SeOOV9ZUY4x5Ns0A16F1uw==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.13.3 version: 3.13.3

View File

@@ -22,6 +22,26 @@
} }
} }
] ]
},
{
"id": "72e6501450a46859",
"type": "tabs",
"children": [
{
"id": "06bc3d52246b600b",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "00 INBOX/ai/steamcmd servers.md",
"mode": "source",
"source": true
},
"icon": "lucide-file",
"title": "steamcmd servers"
}
}
]
} }
], ],
"direction": "vertical" "direction": "vertical"
@@ -233,13 +253,16 @@
}, },
"active": "0c587e5452c16787", "active": "0c587e5452c16787",
"lastOpenFiles": [ "lastOpenFiles": [
"00 INBOX/how to setup a steamcmd server.md",
"00 INBOX/ai/steamcmd servers.md",
"03 wrk/ariadne.md",
"00 INBOX/ai/ariadne-cli-skeleton.md",
"00 INBOX/ai/ariadne-project-goals.md", "00 INBOX/ai/ariadne-project-goals.md",
"TODO.md",
"00 INBOX/ai/dictionary-cli.md", "00 INBOX/ai/dictionary-cli.md",
"00 INBOX/ai/dictionary-skill.md", "00 INBOX/ai/dictionary-skill.md",
"03 wrk/falinks.md", "03 wrk/falinks.md",
"03 wrk/ariadne.md",
"03 wrk/machoke.md", "03 wrk/machoke.md",
"TODO.md",
"00 INBOX/home assistant agent integration.md", "00 INBOX/home assistant agent integration.md",
"02 notes/falinks public surfaces.md", "02 notes/falinks public surfaces.md",
"03 wrk/machoke.md~", "03 wrk/machoke.md~",
@@ -261,8 +284,6 @@
"00 INBOX/ai/hoopa design review findings.md", "00 INBOX/ai/hoopa design review findings.md",
"00 INBOX/ai/hoopa follow up decisions.md", "00 INBOX/ai/hoopa follow up decisions.md",
"01 sources/obsidian and ai were made for each other.md", "01 sources/obsidian and ai were made for each other.md",
"99 meta/technical writing.md",
"99 meta/dictionary.md",
"99 meta/Untitled", "99 meta/Untitled",
"03 wrk", "03 wrk",
"02 notes", "02 notes",

View File

@@ -0,0 +1,125 @@
---
date-created: 2026-09-10 12:01
---
# How to set up a `steamcmd` server
This note explains how to add a SteamCMD-managed game server to the NixOS configuration.
Use these existing files as examples:
- `configs/nixos/modules/services/steamcmd.nix`
- `configs/nixos/modules/services/games/valheim.nix`
- `configs/nixos/guests/valheim.nix`
- `configs/nixos/hosts/pikachu/default.nix`
## Understand the layers
The shared `steamcmd` module installs and runs a dedicated server.
It owns the low-level systemd service, update service, user, directories, and firewall ports.
A game module maps game-specific options onto `modules.services.steamcmd`.
It hides Steam app IDs, executable names, launch arguments, and port calculations.
A guest module maps host-level storage, secrets, and resource limits onto the game module.
It keeps host paths outside the guest's interior configuration.
A host enables the guest and supplies network placement, storage paths, and routing.
## Create the game module
1. Create `configs/nixos/modules/services/games/<game>.nix`.
2. Add an option namespace at `modules.services.games.<game>`.
3. Add an `enable` option with `lib.mkEnableOption`.
4. Add options for game-specific settings.
5. Add options for `steamDir`, `installDir`, and `stateDir`.
6. Use `lib.mkIf config.modules.services.games.<game>.enable` for the module configuration.
7. Set `modules.services.steamcmd.enable = true`.
8. Pass `steamDir`, `installDir`, and `stateDir` to `modules.services.steamcmd`.
9. Set `serviceName` to the game service name.
10. Set `appId` to the Steam dedicated-server app ID.
11. Set `executable` to the server executable or a generated wrapper script.
12. Set `arguments` to the game launch arguments.
13. Set `udpPorts` to every UDP port the server needs.
14. Put secrets in runtime files instead of option values.
15. Read secret files from a wrapper script when the server needs secret values.
Use `configs/nixos/modules/services/games/valheim.nix` as the main pattern.
It wraps the executable because Valheim receives its password as a command-line argument.
## Create the guest module
1. Create `configs/nixos/guests/<game>.nix`.
2. Add an option namespace at `guests.<game>`.
3. Add host-path options for the SteamCMD metadata, install, and state directories.
4. Give host-path options a `null` default.
5. Add assertions that require each host path when the guest is enabled.
6. Define guest-internal paths in a `let` block.
7. Enable `guests.<game>.interior.modules.services.games.<game>`.
8. Pass the guest-internal paths to the game module.
9. Add mounts that bind host paths to the guest-internal paths.
10. Set directory mount `owner`, `group`, and `mode` together.
11. Mount secret files under `/run/secrets/...` when the game needs secrets.
12. Declare matching `sops.secrets` entries for those secret files.
13. Set conservative default resource limits under `guests.<game>.limits`.
Use `configs/nixos/guests/valheim.nix` as the main pattern.
It mounts the password secret into the guest and passes only the runtime path inward.
## Enable the guest on a host
1. Open the target host file under `configs/nixos/hosts/<host>/default.nix`.
2. Set `guests.<game>.enable = true`.
3. Set `vlan` and `address` for the guest.
4. Set host storage paths for SteamCMD metadata, server files, and state.
5. Set game options such as server name and port.
6. Enable reverse-proxy routing when the server needs external access.
7. Route every game UDP port exposed by the guest.
8. Add required secrets to the host's SOPS files.
Use the Valheim section in `configs/nixos/hosts/pikachu/default.nix` as the host-level pattern.
## Test the change
1. Stage new Nix files before evaluation.
Flake evaluation ignores untracked files.
```console
$ git add configs/nixos/modules/services/games/<game>.nix configs/nixos/guests/<game>.nix
```
2. Build the target host configuration.
```console
$ nixos-rebuild build --flake .#<host>
```
3. Deploy the host when the build succeeds.
```console
$ sudo nixos-rebuild switch --flake .#<host>
```
4. Enter the guest from the host.
```console
$ sudo machinectl shell <game>
```
5. Check the update service inside the guest.
```console
$ systemctl status steamcmd-<game>-update.service
```
6. Check the game service inside the guest.
```console
$ systemctl status <game>.service
```
7. Inspect the logs when either service fails.
```console
$ journalctl -u steamcmd-<game>-update.service -u <game>.service
```

View File

@@ -45,7 +45,7 @@
- subagents - subagents
- test-driven-development - test-driven-development
## pikachu proxmox -> nixos ## pikachu + pichu proxmox -> nixos
- lidarr (requires raichu) - lidarr (requires raichu)
- immich - immich
@@ -57,16 +57,60 @@
- adguard - adguard
- homepage - homepage
## raichu proxmox -> nixos
- dwn torrent (vpn)
- dwn usenet (vpn)
- dwn-torrent-novpn
- prowlarr
- flaresolverr
- radarr
- sonarr
- radarr-anime
- sonarr-anime
- configarr
- lidarr
- audiobookshelf
- portainer
- seerr
- plex
- pterodactyl
- palworld old
- project zomboid
- minecraft
- cobblemon
# learning # learning
- https://www.youtube.com/@EeroAlvar/videos - YouTube
- EeroAlvar
- https://www.youtube.com/watch?v=DzhLFiblPUk
- https://www.youtube.com/watch?v=hQGAwD9vkLo
- https://www.youtube.com/watch?v=hhtExS5UQBI
- https://www.youtube.com/watch?v=Y1xO9OsCBC4
- https://www.youtube.com/watch?v=DWWrLlM3gwQ
- https://www.youtube.com/watch?v=yHL-gmkzC-4
- https://www.youtube.com/watch?v=KRVYUkM16hE
- https://www.youtube.com/watch?v=RxdvzenLcl4
- https://www.youtube.com/watch?v=mdbpIpPSPwQ
- https://www.youtube.com/watch?v=GHsq0klC_4g
- https://www.youtube.com/watch?v=ciC6ffUqI8k
- https://www.youtube.com/watch?v=gYxZt9Qe0fk
- https://www.youtube.com/watch?v=0tqty8ltKDA
- https://www.youtube.com/watch?v=kzcI5F4tGiU
- https://www.youtube.com/watch?v=iKwPaB5TUdI
- https://www.youtube.com/watch?v=A-CtdQFRX64
# tech debt # tech debt
- better remote deployment
## nix refactor ## nix refactor
```text ```text
nixos
├── guests ├── guests
│ ├── actualbudget.nix │ ├── actualbudget.nix
│ ├── copyparty.nix │ ├── copyparty.nix
│ └── reverse-proxy.nix │ ├── reverse-proxy.nix
│ └── valheim.nix
├── hosts ├── hosts
│ ├── neogaia │ ├── neogaia
│ │ ├── default.nix │ │ ├── default.nix
@@ -120,9 +164,12 @@
│ │ ├── vlans.nix │ │ ├── vlans.nix
│ │ └── vpn-client.nix │ │ └── vpn-client.nix
│ ├── services │ ├── services
│ │ ├── games
│ │ │ └── valheim.nix
│ │ ├── actualbudget.nix │ │ ├── actualbudget.nix
│ │ ├── copyparty.nix │ │ ├── copyparty.nix
│ │ └── reverse-proxy.nix │ │ ├── reverse-proxy.nix
│ │ └── steamcmd.nix
│ ├── shell │ ├── shell
│ │ ├── direnv │ │ ├── direnv
│ │ │ ├── default.nix │ │ │ ├── default.nix