nixos: describe asxpio-services as a NixOS system

asxpio-services is to move from Debian with Docker to NixOS with
Podman. This is the target system; nothing is switched over yet.

One flake holds what was spread over cloud-init, Ansible roles and
hand-made units: network and WireGuard, users and SSH restrictions,
firewall, the weekly image prune, and the four container stacks
(Paper and Fossils Minecraft, SillyTavern, the Forgejo runner), each
as a systemd unit under Podman. The runner keeps running as a
container and starts jobs through Podman's Docker-compatible socket.

Secrets (WireGuard keys, RCON and CurseForge keys) are encrypted with
sops for the admin's age key and the server's SSH host key, so the
repository holds no plaintext secret and CI needs none of them.

Containers that own data wait for /opt/.restored. On a freshly
installed disk a game server would otherwise generate a new world
exactly where the restore is about to land.

Tried on a throwaway server (.#rehearsal): install with nixos-anywhere
keeping a given host key, secret decryption, all stacks, a real Paper
server stopping with a full save, what CI jobs need from the engine
(short image names, the nix-store volume, service containers by name,
a kaniko build, the socket inside a job), an update over SSH and a
reboot.

migration/ has the scripts that move the data through object storage,
and a workflow builds the system on every push and switches the host
on main.

Assisted-by: Claude:fable-5-2
This commit is contained in:
Sergei Poljanski 2026-11-12 17:00:59 +04:00
commit 8bd9c653f7
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
16 changed files with 1028 additions and 0 deletions

View file

@ -0,0 +1,185 @@
# Containers run under Podman, one systemd unit each
# (podman-<name>.service). Game data stays in /opt and is not managed here.
{
config,
lib,
pkgs,
...
}:
let
podman = lib.getExe pkgs.podman;
# Time for a Minecraft server to save the world before it is killed
stopTimeout = 120;
# Nothing that owns data starts on a freshly installed disk: a game
# server would generate a new world where the restore is about to land.
# After restoring /opt, `touch /opt/.restored` and start the units.
needsData.unitConfig.ConditionPathExists = "/opt/.restored";
gameServer = needsData // {
serviceConfig = {
TimeoutStopSec = lib.mkForce (stopTimeout + 30);
# A JVM stopped by SIGTERM exits 143 after saving; that is a clean stop
SuccessExitStatus = [ 143 ];
};
};
in
{
virtualisation.podman = {
enable = true;
dockerCompat = true; # `docker` runs podman
dockerSocket.enable = true; # /run/docker.sock, used by the CI runner
defaultNetwork.settings.dns_enabled = true;
autoPrune = {
enable = true;
dates = "Mon 03:00";
flags = [ "--all" ];
};
};
virtualisation.containers.registries.search = [ "docker.io" ];
virtualisation.oci-containers.backend = "podman";
sops.secrets."minecraft_fossils/env" = { };
virtualisation.oci-containers.containers = {
# Paper server; the jar and the world live in /opt/minecraft
minecraft = {
image = "docker.io/library/eclipse-temurin:21-jre";
workdir = "/data";
cmd = [
"java"
"-Xmx4G"
"-Xms2G"
"-jar"
"paper-1.21.11-54.jar"
"nogui"
];
ports = [
"25565:25565"
"19132:19132/udp"
];
volumes = [ "/opt/minecraft:/data" ];
extraOptions = [
"--tty"
"--interactive"
"--stop-timeout=${toString stopTimeout}"
];
};
minecraft-fossils = {
image = "docker.io/itzg/minecraft-server:java21";
environment = {
EULA = "TRUE";
TYPE = "FABRIC";
VERSION = "1.20.1";
FABRIC_LOADER_VERSION = "0.18.4";
MEMORY = "8G";
ONLINE_MODE = "FALSE";
ENFORCE_WHITELIST = "TRUE";
ENFORCE_SECURE_PROFILE = "FALSE";
WHITELIST_FILE = "/data/whitelist.json";
OPS_FILE = "/data/ops.json";
ENABLE_RCON = "TRUE";
MOTD = "[mc.czsk.dev] Fossils Archaeology Revival: Genesis";
USE_AIKAR_FLAGS = "TRUE";
};
environmentFiles = [ config.sops.secrets."minecraft_fossils/env".path ];
ports = [ "25566:25565" ];
volumes = [ "/opt/minecraft-fossils/data:/data" ];
extraOptions = [
"--tty"
"--interactive"
"--network=fossils"
"--stop-timeout=${toString stopTimeout}"
];
};
# Copies whitelist, ops and ban lists from the Paper server
minecraft-fossils-wlsync = {
image = "docker.io/library/alpine:3";
dependsOn = [ "minecraft-fossils" ];
environment = {
RCON_HOST = "minecraft-fossils";
INTERVAL = "15";
};
environmentFiles = [ config.sops.secrets."minecraft_fossils/env".path ];
volumes = [
"/opt/minecraft:/src:ro"
"/opt/minecraft-fossils/data:/dst"
"${./wlsync.sh}:/wlsync.sh:ro"
];
cmd = [
"sh"
"/wlsync.sh"
];
extraOptions = [ "--network=fossils" ];
};
# Reachable on the WireGuard address only
sillytavern = {
image = "ghcr.io/sillytavern/sillytavern:1.19.0";
ports = [ "10.20.0.20:8000:8000" ];
volumes = [
"/opt/sillytavern/config:/home/node/app/config"
"/opt/sillytavern/data:/home/node/app/data"
"/opt/sillytavern/plugins:/home/node/app/plugins"
];
};
};
systemd.services = {
# Network shared by the Fossils server and its sidecar
podman-network-fossils = {
description = "Podman network fossils";
wantedBy = [ "multi-user.target" ];
before = [
"podman-minecraft-fossils.service"
"podman-minecraft-fossils-wlsync.service"
];
requiredBy = [
"podman-minecraft-fossils.service"
"podman-minecraft-fossils-wlsync.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = "${podman} network exists fossils || ${podman} network create fossils";
};
podman-minecraft = gameServer;
podman-minecraft-fossils = gameServer;
podman-minecraft-fossils-wlsync = needsData;
# The WireGuard address must exist before a port can be bound to it
podman-sillytavern = needsData // {
after = [ "wireguard-asxpio-services.service" ];
requires = [ "wireguard-asxpio-services.service" ];
};
minecraft-fossils-restart = {
description = "Daily restart of minecraft-fossils";
serviceConfig.Type = "oneshot";
script = "systemctl restart podman-minecraft-fossils.service";
};
};
systemd.timers.minecraft-fossils-restart = {
description = "Restart minecraft-fossils daily at 08:00 Europe/Tallinn";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 08:00:00 Europe/Tallinn";
Persistent = true;
};
};
systemd.tmpfiles.rules = [
"d /opt 0755 root root -"
"d /opt/minecraft 0755 root root -"
"d /opt/minecraft-fossils 0755 asxpi users -"
"d /opt/minecraft-fossils/data 0755 asxpi users -"
"d /opt/sillytavern/config 0755 root root -"
"d /opt/sillytavern/data 0755 root root -"
"d /opt/sillytavern/plugins 0755 root root -"
];
}