Ringtail

Service host and gaming PC. Custom-built PC running NixOS.

Specifications

PropertyValue
MotherboardASUS ROG Crosshair VI Hero (Wi-Fi AC)
CPUAMD Ryzen 7 1700X (8-core/16-thread, 3.4 GHz)
RAM32 GB DDR4 (4x8 GB Corsair Vengeance CMK16GX4M2B3200C16, 3200 MT/s DOCP)
GPUNVIDIA GeForce RTX 4080 (AD103, 16 GB VRAM)
MonitorHP OMEN 27i IPS (2560x1440, 165 Hz, DisplayPort)
Storage (boot)Samsung 970 PRO 1TB NVMe
Storage (SATA)Samsung 850 EVO 1TB (/mnt/games), 850 EVO 500GB (/mnt/storage1), 840 PRO 120GB (/mnt/storage2)
PeripheralsDas Keyboard 4, Logitech MX Master 3, 8BitDo Ultimate 2 controller
OSNixOS 25.11 (Sway/Wayland)
Tailscale hostnameringtail.tail8d86e.ts.net

Networking

PropertyValue
Interface (wired)enp5s0
IP192.168.1.21/24 (static, set by NixOS scripted networking)
Gateway192.168.1.1 (UX7)
DNS192.168.1.1, 1.1.1.1 (used as Tailscale’s upstream resolvers; /etc/resolv.conf is owned by Tailscale’s MagicDNS at 100.100.100.100)
DHCP reservationUniFi “Fixed IP” tied to ringtail’s MAC; belt-and-suspenders so the UX7 won’t lease 192.168.1.21 to anyone else even though ringtail no longer asks for it
Wirelesswlp6s0 still managed by NetworkManager as a fallback path

NetworkManager is enabled but explicitly excluded from managing enp5s0 via networking.networkmanager.unmanaged = [ "interface-name:enp5s0" ]. The wired address is configured by a deterministic network-addresses-enp5s0.service oneshot — no daemon, no lease, no renewal.

Software

Managed declaratively via nixos/ringtail/configuration.nix. Home-manager handles ringtail-specific sway/waybar config; chezmoi manages cross-platform dotfiles.

  • Desktop: Sway (Wayland, Catppuccin Macchiato theme) with waybar and wezterm
  • Browser: LibreWolf
  • Gaming: Steam (library on /mnt/games), 8BitDo controller via Steam Input
  • Audio: Edifier R1280DBs (Bluetooth), PipeWire
  • Secrets: 1Password CLI + GUI (NixOS modules for polkit/setgid integration)
  • Runtimes: mise manages Node, Python, Rust, .NET; nix-ld enables dynamically linked binaries
  • Dotfiles: chezmoi init eblume && chezmoi apply
  • Shell helpers: the myip fish function (public-IP lookup) is managed declaratively via home-manager xdg.configFile. Ringtail’s other fish config (config.fish, conf.d/, most functions) is still hand-placed and not yet under nix — a known drift.

Deployment

mise run provision-ringtail

This locks new flake inputs via Dagger, verifies the current commit is pushed to forge, then deploys the exact commit via ansible. If the lockfile changed, it stages the file and exits so you can commit and re-run. To update all inputs to latest versions, see manage-lockfile.

Activation runs detached from the SSH session, as a transient systemd unit named blumeops-nixos-rebuild. A switch restarts sshd, tailscaled and the network stack, so as a child of the session it could be killed partway through by the teardown it caused. The play starts the unit, reconnects, polls it to completion, and reports systemd’s Result alongside the unit’s journal on failure.

The practical consequence: losing your connection no longer aborts the switch. If provision-ringtail dies on your end mid-activation, the rebuild is still running on the box — watch it with ssh ringtail 'journalctl -fu blumeops-nixos-rebuild' rather than re-running, and let it finish before provisioning again.

K3s Cluster

Ringtail runs a single-node k3s cluster for native amd64 workloads, registered in ArgoCD on indri as k3s-ringtail.

  • Disabled components: Traefik, ServiceLB, metrics-server (minimal footprint)
  • TLS SAN: ringtail.tail8d86e.ts.net (ArgoCD connects via Tailscale)
  • Registry mirrors: Containerd pulls through Zot on indri (registry.ops.eblu.me)
  • Token: /etc/k3s/token (generated on first provision)
  • Kubeconfig: /etc/rancher/k3s/k3s.yaml, root-only via --write-kubeconfig-mode=600. ringtail is a multi-user host — the agent uid is a co-tenant — so a readable admin kubeconfig is a cluster-admin grant to every local account. See agent-workspaces §Isolation.

Secrets Management

1Password Connect + External Secrets Operator syncs secrets from 1Password to k8s, matching the indri pattern. Bootstrap credentials (op-credentials, onepassword-token) are provisioned by Ansible; ArgoCD manages the operator stack.

Sync order: 1password-connect-ringtail external-secrets-crds-ringtail external-secrets-ringtail external-secrets-config-ringtail

Workloads

WorkloadNamespaceNotes
frigatefrigateNVR with GPU-accelerated detection (RTX 4080)
frigate-notifyfrigateWebapi-to-ntfy alert bridge
authentikauthentikOIDC identity provider
ntfyntfyPush notification server
ollamaollamaLLM inference with GPU (RTX 4080)
nvidia-device-pluginnvidia-device-pluginExposes GPU to pods via CDI + nvidia RuntimeClass

Manual Cluster Registration

After first provision, register the cluster in ArgoCD:

ssh ringtail 'sudo cat /etc/rancher/k3s/k3s.yaml' | \
  sed 's|127.0.0.1|ringtail.tail8d86e.ts.net|' > /tmp/k3s-ringtail.yaml
set -x KUBECONFIG /tmp/k3s-ringtail.yaml
kubectl get nodes  # verify access
argocd cluster add default --name k3s-ringtail

Systemd Services

Snowflake Proxy

A Tor snowflake-proxy that helps censored users reach the Tor network. Runs as a simple systemd service using the snowflake nixpkgs package. The proxy is not a Tor exit node — it only bridges encrypted WebRTC connections to Tor relays.

PropertyValue
Service unitsnowflake-proxy.service
Metricslocalhost:9999/metrics (Prometheus)

Forgejo Actions Runner

A native Forgejo Actions runner (ringtail-nix-builder) runs as a systemd service via the NixOS services.gitea-actions-runner module. It builds containers using nix-build and pushes them to Zot via skopeo.

PropertyValue
Labelnix-container-builder
ExecutionHost (no containers)
Token/etc/forgejo-runner/token.env (provisioned by Ansible)
Service unitgitea-runner-nix_container_builder.service

The runner resolves <nixpkgs> from the flake registry at build time. Container trust policy (/etc/containers/policy.json) and registry search order (/etc/containers/registries.conf) are configured minimally in configuration.nix for skopeo — no full virtualisation.containers module needed.

Factorio Server

A private Factorio dedicated server (services.factorio, nixos/ringtail/factorio.nix) — BlumeOps’ first externally-shared service. It listens on UDP 34197 with openFirewall = false: the port is reachable only over the already-trusted tailscale0 interface, never the LAN or internet. Named factorio.ops.eblu.me via an exact A record (in pulumi/gandi) that overrides the *.ops → indri wildcard, since the game is UDP and bypasses Caddy.

PropertyValue
Service unitfactorio.service
PortUDP 34197 (tailnet only)
State / saves/var/lib/factorio
Accessguests are shared onto ringtail (autogroup:shared), granted only udp:34197 on tag:factorio

Guests are shared onto ringtail, never invited as members, so they inherit none of the member-facing services. See host-factorio-for-a-guest for the onboarding and revocation flow, and tailscale for the ACL model.

Heph Spokes

Two hephd spokes sync this host to the indri heph hub. They share the version pin and install machinery (nixos/ringtail/heph-common.nix) but hold different login identities and token stores — hephd sockets/dbs are per-user, and keeping Erich’s access decoupled from the agent kill switches is the point:

Propertyagent-heph-spokeeblume-heph-spoke
Useragenteblume
Configagent-heph-spoke.nixheph-eblume.nix
Identityheph-agents (revocable)Erich himself
Token storeagents 1Password vault (op command store)~/.config/heph/hub-token.json (0600, --token-file)
Unit scopesystemuser (systemctl --user, linger enabled)

The scope difference is load-bearing: hephd and the heph CLI meet at the default socket path, which depends on XDG_RUNTIME_DIR. The agent’s sessions run inside the equally env-less workspace service, so both sides use the ~/.local/share/heph fallback; eblume’s interactive shells resolve /run/user/1000/heph/hephd.sock, so his spoke must run in the systemd user manager (status/logs: systemctl --user status eblume-heph-spoke, journalctl --user -u eblume-heph-spoke).

Both adopt the same hub owner id, so they operate on the same nodes. Each user gets its own heph/hephd at ~/.cargo/bin via a *-heph-install oneshot (timer-fired, off the activation path; the spoke starts via a path unit the moment the binary lands — see heph-common.nix for the quartet). When the pin moves, the oneshot restarts the matching spoke after the install so the daemon never lags the installed binary (the agent’s oneshot runs as root for that one step, dropping to agent via runuser for the cargo work).

One-time seed for the eblume spoke (interactive, as eblume on ringtail; approve in the browser as yourself — no hub-side change needed, you are already the hub owner):

~/.cargo/bin/heph auth login \
  --hub-url http://indri.tail8d86e.ts.net:8787 \
  --issuer https://authentik.ops.eblu.me/application/o/heph/ \
  --client-id heph --token-file ~/.config/heph/hub-token.json

The agent spoke’s seeding is fiddlier — see bootstrap-agent-workspaces §7.

MyEVE Heph Sync

An hourly timer (nixos/ringtail/myeve-heph-sync.nix) that publishes EVE Online game state — PI extractor expiry, finished industry jobs, skill-queue exhaustion, undercut market orders — into heph as tasks under the MyEVE project, so the game’s obligations rank alongside the rest of Erich’s chores.

PropertyValue
Unitsmyeve-heph-sync.service / .timer (user scope)
ScheduleOnCalendar=hourly, RandomizedDelaySec=10m, Persistent=true
Sync logic~/code/personal/myeve/scripts/heph-sync/eve_chores.py (read from the working tree, not packaged)
Credentials~/code/personal/myeve/secrets/esi-token.json — an ESI game token, deliberately not a blumeops secret
Status / logssystemctl --user status myeve-heph-sync.timer, journalctl --user -u myeve-heph-sync

User scope for the same reason as the eblume spoke above: the sync shells out to heph, which needs XDG_RUNTIME_DIR to find hephd.sock. It runs --apply (write mode) unattended; --commit is an accepted alias of that flag and has nothing to do with git.

The unit skips cleanly rather than failing when the heph CLI, the myeve checkout, the ESI token, or the spoke’s socket is absent — a missing piece costs one tick, not a red unit. It does fail loudly if the ESI refresh token is revoked, which is the intended behaviour: better a failed unit than a silently stale chore list.

Reconciliation is keyed on a myeve-key: line written into each task’s canonical-context doc, and the heph store is the only state — there is no cache file. Tasks filed by hand under MyEVE carry no key and are never touched. To mute a chore the game cannot see you have abandoned, push it to blue (On Deck); blue is still outstanding, so the reconciler leaves it alone. Marking it done while ESI still reports the obligation just invites the next tick to file it again.

Skagit CCE Watch

An hourly timer (nixos/ringtail/skagit-cce-watch.nix) that watches the Skagit Community & Continuing Education course catalog (a public ASPX category page) for a new ceramics class. Each listed class carries an “Item Number” — the canonical CCE class id — and the watch files a red heph task under the Ceramics project the first time a class whose title looks like ceramics appears, so it surfaces in Erich’s agenda the moment the college lists it.

PropertyValue
Unitsskagit-cce-watch.service / .timer (user scope)
ScheduleOnCalendar=hourly, RandomizedDelaySec=10m, Persistent=true
Watch logicnixos/ringtail/skagit-cce-watch.py (stdlib-only, read via builtins.readFile and packaged by the module)
State~/.local/state/skagit-cce-watch/state.json — the set of known item numbers; a real dir under eblume’s home so it survives rebuilds
Alertheph task … --project Ceramics --attention red --do-date today (+ source link in the task’s context doc), and a best-effort ntfy push to https://ntfy.ops.eblu.me/ceramics (tapping the notification opens the course page)
Status / logssystemctl --user status skagit-cce-watch.timer, journalctl --user -u skagit-cce-watch

User scope for the same reason as the MyEVE sync above: it shells out to heph, which needs XDG_RUNTIME_DIR to find hephd.sock. It targets eblume’s own spoke, not the agent’s, so the alert lands in Erich’s primary agenda.

The unit skips cleanly when the heph CLI or the spoke socket is not up, and fails loudly (leaving state untouched) if the page parses to zero courses — a page or parse regression then goes red in the journal rather than wiping the baseline or flooding alerts. First run establishes a baseline and files nothing, so an already-listed course does not wake Erich; a new non-ceramics class is recorded (so it is never alerted on) but files nothing. The ceramics match is a loose term net in the script (CERAMICS) — extend it there if the college starts using a name it does not cover.

On a new ceramics class it also fires a best-effort ntfy push to the self-hosted ntfy (ntfy.ops.eblu.me, topic ceramics) so the alert reaches Erich’s phone; the push carries X-Click so tapping it opens the course page. A push failure never fails the tick — the red task is the primary alert. Subscribing is client-side only: in the ntfy app add a subscription with server https://ntfy.ops.eblu.me, topic ceramics (nothing is configured server-side; a topic needs no backend setup). Knobs: SKAGIT_CCE_NTFY_URL, SKAGIT_CCE_NTFY_TOPIC, and optional SKAGIT_CCE_NTFY_TOKEN (sent as a Bearer token) if/when the ntfy gains auth.

Pinned Service Versions

Versioned services (forgejo-runner, snowflake, k3s) are pinned via a nixpkgs-services overlay in flake.nix, separate from the rolling nixpkgs input. This prevents nix flake update from silently upgrading them. The Dagger flake-update pipeline excludes nixpkgs-services automatically. See review-services for the upgrade procedure.

Maintenance Notes

1Password: Desktop app must be running for op CLI. Use $mod+Shift+minus to send to scratchpad.

NVIDIA: Proprietary drivers. Sway launched with --unsupported-gpu via greetd.

No TPM: systemd.tpm2.enable = false prevents 90s boot delay.

RAM speed: Running at 3200 MT/s via DOCP 1 (BIOS 8902+).