# services/pelican.nix — Pelican game-server panel (Pterodactyl's successor). # # Neither Pelican nor Pterodactyl is in nixpkgs, so both halves run as # containers (like shelfarr/profilarr). Two pieces: # # panel — Laravel web UI. Single container, SQLite + file cache (no # MariaDB/Redis needed). All state lives in /var/lib/pelican-panel. # wings — Go daemon that actually starts game servers. Talks to the *host* # docker daemon over docker.sock, so game containers are siblings, # not nested. That's why every wings path below is mounted at the # same path inside the container as outside: wings hands those paths # to the host dockerd, which resolves them on the host. # # Neither is declarative — servers, eggs and users are configured in the web # UI and stored in the panel's SQLite DB. Back up /var/lib/pelican-panel and # /var/lib/pelican (server files); the nix side here is only the plumbing. # # FIRST-RUN (wings crash-loops until step 3 — that's expected): # 1. https://panel.nordhammer.it → installer wizard, make admin user. # 2. Admin → Nodes → Create. FQDN node.nordhammer.it, SSL *on*, port 443 # (nginx terminates TLS and proxies to wings' plain :8080 below). # Allocations: IP 10.0.0.1, ports 25565-25600 (matches ports.toml). # 3. Node → Configuration tab → copy the generated YAML to # /etc/pelican/config.yml on this host, then # `systemctl restart docker-pelican-wings`. { config, pkgs, lib, ... }: let # Replaces the image's default Caddyfile, which would try to fetch its own # Let's Encrypt cert. nginx already terminates TLS for us, so serve plain # HTTP on :80 and trust the forwarded headers. private_ranges rather than a # literal IP: requests arrive from the docker bridge gateway, not 127.0.0.1, # and the published port is localhost-only anyway. caddyfile = pkgs.writeText "pelican-Caddyfile" '' { admin off servers { trusted_proxies static private_ranges } } :80 { root * /var/www/html/public encode gzip php_fastcgi 127.0.0.1:9000 file_server } ''; in { config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { systemd.tmpfiles.rules = [ "d /var/lib/pelican-panel 0755 root root -" "d /var/lib/pelican-panel/logs 0755 root root -" "d /etc/pelican 0755 root root -" "d /var/lib/pelican 0755 root root -" "d /var/log/pelican 0755 root root -" "d /tmp/pelican 0755 root root -" ]; virtualisation.oci-containers.containers.pelican-panel = { image = "ghcr.io/pelican-dev/panel:latest"; volumes = [ "/var/lib/pelican-panel:/pelican-data" "/var/lib/pelican-panel/logs:/var/www/html/storage/logs" "${caddyfile}:/etc/caddy/Caddyfile:ro" ]; # Localhost-only; nginx fronts it (see nginx.nix). ports = [ "127.0.0.1:8092:80" ]; environment = { XDG_DATA_HOME = "/pelican-data"; APP_URL = "https://panel.nordhammer.it"; ADMIN_EMAIL = "fredrik@nordhammer.it"; APP_ENV = "production"; APP_DEBUG = "false"; # Laravel otherwise renders http:// asset URLs behind the proxy. TRUSTED_PROXIES = "*"; TZ = "Europe/Stockholm"; }; extraOptions = [ "--add-host=host.docker.internal:host-gateway" ]; }; virtualisation.oci-containers.containers.pelican-wings = { image = "ghcr.io/pelican-dev/wings:latest"; volumes = [ "/var/run/docker.sock:/var/run/docker.sock" "/var/lib/docker/containers/:/var/lib/docker/containers/" "/etc/pelican/:/etc/pelican/" "/var/lib/pelican/:/var/lib/pelican/" "/var/log/pelican/:/var/log/pelican/" "/tmp/pelican/:/tmp/pelican/" "/etc/ssl/certs:/etc/ssl/certs:ro" ]; ports = [ # Daemon API + console websocket — nginx fronts it as node.nordhammer.it. "127.0.0.1:8443:8080" # SFTP into server files. LAN-reachable; not in ports.toml, so no WAN. "2022:2022" ]; environment.TZ = "Europe/Stockholm"; # Image is distroless with no entrypoint script — needs a tty allocated. extraOptions = [ "--tty" ]; }; # Same guard the 7DTD containers had: wings exits immediately until # /etc/pelican/config.yml exists, and an unbounded restart loop spawns a # veth pair every few seconds, flooding systemd-networkd and risking WAN # DHCP on this host (it's the router). Give up after 5 tries in 5 min. systemd.services."docker-pelican-wings".serviceConfig = { Restart = lib.mkForce "on-failure"; RestartSec = "30s"; StartLimitIntervalSec = 300; StartLimitBurst = 5; }; }; }