My self-hosted infrastructure. https://hu-tao.dev
  • Nix 79.1%
  • HCL 14%
  • Python 5.1%
  • Shell 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ivan bde492839a
All checks were successful
Modrinth / Publish the packs (push) Successful in 3m41s
CI / Format, lint and evaluate (push) Successful in 7m15s
Option packs / Build and boot the packs with options (push) Successful in 6m41s
Pages / Build the handbook (push) Successful in 3m45s
docs(minecraft): say why Modrinth refuses a review in packs/modrinth.sh
The bare 400 was the publishing checklist, not the API: once the summary, description and environment passed, CI submitted all four packs itself.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:40:18 +02:00
.forgejo/workflows feat(minecraft): publish the packs to Modrinth on every push 2026-11-01 17:35:53 +02:00
.github/workflows perf(ci): build and boot only affected Minecraft worlds 2026-10-31 00:42:21 +02:00
docs feat(minecraft): add Traveler's Backpack to world 4 2026-11-01 17:19:24 +02:00
modules feat(minecraft): let FallingTree fell trees of up to 200 logs 2026-10-31 22:47:13 +02:00
packages feat(minecraft): deploy pinned Horizon binary server 2026-10-31 01:33:18 +02:00
packs docs(minecraft): say why Modrinth refuses a review in packs/modrinth.sh 2026-11-01 19:40:18 +02:00
runner feat(runner): add the runner-hetzner system 2026-09-19 11:55:39 +03:00
scripts refactor(minecraft): split the rcon wrapper into named functions 2026-10-25 23:04:38 +02:00
tests feat(minecraft): build option packs with mcopts 2026-10-31 18:25:14 +02:00
tofu feat(minecraft): deploy pinned Horizon binary server 2026-10-31 01:33:18 +02:00
.gitignore fix(minecraft): bound startup reads and boot built options packs 2026-10-31 00:21:22 +02:00
.markdownlint-cli2.yaml feat(minecraft): give every world a pinned modpack, players included 2026-10-25 13:47:38 +02:00
.pre-commit-config.yaml fix(minecraft): stamp options.txt's data version, fail on unloadable ones 2026-10-31 13:23:46 +02:00
.sops.yaml feat: set up automatic tailscale provisioning 2026-08-28 00:43:22 +03:00
configuration.nix feat(mirror-sync): keep the craft mirrors in step with upstream hourly 2026-10-11 22:37:22 +03:00
disk-config.nix chore(vm): raise the local test VM disk to 32G 2026-08-29 20:41:49 +03:00
flake.lock chore(deps): take mcopts with checked options as a type 2026-10-31 18:45:33 +02:00
flake.nix fix(ci): put jq and unzip in the ci shell for packs/modrinth.sh 2026-11-01 17:41:31 +02:00
LICENSE chore(license): license the repo under MIT 2026-10-29 13:42:09 +02:00
README.md feat(minecraft): name packs by address, list the world in option packs 2026-10-31 12:04:24 +02:00
renovate.json5 test(minecraft): boot packs in CI 2026-10-30 23:45:34 +02:00
secrets.example.yaml feat(minecraft): add a fourth world with a pack light enough for integrated graphics 2026-10-25 18:30:27 +02:00
secrets.yaml feat(minecraft): add a fourth world with a pack light enough for integrated graphics 2026-10-25 18:30:27 +02:00

VPS Architecture

CI Icon Pages Icon Handbook Icon Status Icon NixOS Icon

One flake that describes a whole VPS — disks, firewall, secrets, every container service — plus a second machine that runs its CI, and the OpenTofu that creates both and publishes their DNS.

Ported from the Ansible repo it replaces. What that repo needed three layers for (docker's restart policy, a boot-time docker-services.sh sweep, and an Ansible converge run from a workstation) is one layer here: systemd units built from this flake. There is no state on the server that this repo does not describe, and nothing to remember to run.

.
├── configuration.nix     # the import list, nothing else
├── disk-config.nix       # disko layout + the local QEMU VM
├── flake.nix             # vps, vps-hetzner and runner-hetzner
├── .pre-commit-config.yaml  # the checks, shared by the commit hook and CI
├── .markdownlint-cli2.yaml  # what the markdown linter checks, and what it skips
├── .forgejo/workflows/   # CI and pages, run on the dedicated runner box
├── .github/workflows/    # CI and option packs, run on the GitHub mirror
├── docs/                 # the handbook — mdBook source in docs/src/
├── runner/               # the CI runner's own configuration.nix
├── tests/                # VM and network tests behind checks.*
├── packs/                # the Minecraft worlds' modpacks (packwiz), see the handbook
├── renovate.json5        # this repo's Renovate config
├── modules/
│   ├── options.nix       # infra.* — domain, tailnet IP, docker networks
│   ├── secrets.nix       # the sops keys (serenity's live with their modules)
│   ├── acme.nix          # one certificate, DNS-01 via Cloudflare
│   ├── firewall.nix      # nftables, including the container forward path
│   ├── services.nix      # sshd, tailscale, fail2ban, docker
│   ├── security.nix      # auditd rules
│   ├── postgres.nix      # host postgres + pgbouncer, pg_dumpall backups
│   ├── backups.nix       # restic → B2, daily + weekly quiescent minecraft
│   ├── image-archive.nix # docker save every image; restore one when a pull fails
│   ├── syncthing.nix     # tailnet-only, /home/hutao/syncthing
│   ├── tarpit.nix        # endlessh-go on 222/2022/22222, and its prometheus
│   ├── pages-pull.nix    # fetches published artifacts into the pages volume
│   ├── pages-hook.nix    # webhook receiver that triggers pages-pull
│   ├── renovate.nix      # daily Renovate run, on the host
│   ├── boot.nix hardware.nix ids.nix networking.nix nix.nix users.nix
│   ├── runner/           # everything only the CI runner has
│   └── containers/       # one module per service
└── tofu/                 # hcloud server + edge firewall, Cloudflare DNS

The handbook

Everything below is the short version. The long version — how a packet reaches a service, why the CI runner is a separate machine, what to do when a deploy aborts — is in docs/, published at https://docs.hu-tao.dev/.

nix run .#docs -- --open   # writes the mermaid assets first, then serves
Chapter For
Network and trust boundaries the three doors, input vs forward, why there is no private network
The tailnet policy what a tailnet peer may reach, and why tags are the only lever
CI runner isolation the seven layers, the one-way rule, the six-path allow-list
The service stack what runs, and how each thing is reached
Deploying the everyday path, and the circuit breaker behind it
CI and validation what each forge checks and how to run the Minecraft startup test
Failure modes and recovery what rolls back on its own and what does not
Runbook the commands, in the order you want them
Working on the packs the Minecraft modpacks: packwiz, sides, the dependency check

Those links go to the source, which renders fine in Forgejo — diagrams included, since they are mermaid and Forgejo draws them natively.

Playing Minecraft

Three worlds, one address each. Install the world's modpack in Prism Launcher (Add Instance → Import, paste the link) or the Modrinth App, ask to be whitelisted, then join.

World Join at Minecraft Pack
world 1 mc.hu-tao.dev 26.1.2 mc.hu-tao.dev-26.1.2.mrpack
world 2 mc2.hu-tao.dev 1.21.1 mc2.hu-tao.dev-1.21.1.mrpack
world 3 mc3.hu-tao.dev 26.2 mc3.hu-tao.dev-26.2.mrpack
world 4 mc4.hu-tao.dev 26.3 mc4.hu-tao.dev-26.3.mrpack

A sleeping world wakes when you join: you are turned away once while it starts, so join again a minute later. The full guide, including what to do when the game will not start, is Playing in the handbook.

The separate Horizon trial runs the native Rust implementation of Java 26.3 at horizon.hu-tao.dev. Both client and server use pinned upstream binaries; the Fabric packs are separate.

Quick start

nix develop                       # the shell everything below assumes
deploy .#vps                      # the VPS
deploy .#runner-forgejo-runner    # the CI runner, via ProxyJump through the VPS

QEMU_OPTS="-vnc :0" nix run .#default    # the whole system, locally
nix develop -c pre-commit install        # once per clone

Two machines: the VPS holds every service, and a second box runs CI and is treated as hostile. See the introduction.

The things that will bite you

Six, all of them load-bearing, all of them silent when wrong.

A published container port lives in the forward chain, not input. It is DNAT'd in prerouting and then forwarded, so it never touches the input hook. Docker writes its own accepts into the ip filter table, and in nftables every table's chain runs — an accept over there cannot rescue a packet table inet nixos-fw drops. So a policy-drop forward chain with no rules leaves every container unreachable and without egress. When adding a service, its port goes in the forward allow-list; getting that backwards gives you a port the internet can reach that the firewall never authorised.

fail2ban's chain = DOCKER-USER does not translate. That is the right answer for iptables. With networking.nftables on, NixOS resolves banaction-allports to nftables-allports, where chain is only the nftables chain name — so DOCKER-USER would name an input-hooked chain and ban nothing that reaches a container. chain_hook = forward is the part that moves it. See the forgejo-ssh jail in modules/services.nix, and check the live result with nft list table inet f2b-table.

The forgejo container needs --log-opt tag=forgejo. The jail reads the journal, and without the tag the journal identifier is the container ID, which changes on every recreate. The jail then looks perfectly healthy and matches nothing.

A bind-mounted sops secret goes stale. A rendered secret's real path is under a generation directory and path only symlinks to it; docker resolves the symlink at mount time and holds that inode forever. dozzle-users.service and mailserver-dkim.service copy to a stable path first, which is why they exist.

A sops env file changes without restarting anything. sops.templates.<n>.path is a stable path, so rotating a value changes the file's content and nothing else — the unit text is byte-identical and switch-to-configuration finds no unit to restart. The container keeps the old value in its environment until something unrelated recreates it, which can be weeks. restartUnits on the template is the fix: sops-nix diffs the rendered file and restarts only on a real change, so no-op deploys still don't bounce the service. navidrome.env and pages-hook.env do this; the older env templates predate it.

The two firewalls must both allow a port. tofu/modules/hetzner-firewall is the edge and modules/firewall.nix is the host. Each is what survives a misconfiguration of the other, so when something is unreachable, check both.

Configuration

Set your secrets using SOPS + age.

mkdir -p ~/.sops-nix
age-keygen | tee ~/.sops-nix/key.txt > /dev/null
chmod 0600 ~/.sops-nix/key.txt

Then edit them:

SOPS_AGE_KEY_FILE=~/.sops-nix/key.txt nix develop -c sops secrets.yaml

secrets.example.yaml is the template. Every declared sops key must exist — most are in modules/secrets.nix, and the serenity keys sit next to their consumers in postgres.nix and containers/serenity-bot.nix. A missing one makes sops-install-secrets fail during activation — which on a fresh install means the machine boots with no credentials at all, its own login included.

Beyond what the Ansible vault held, this port needs:

Key Why
grafana/admin_user, grafana/admin_password anonymous Admin is off, so this is the only way in
kuma/healthcheck_url the out-of-band status-page probe; a separate check from the backup one because they fail for different reasons
minecraft/rcon_password RCON is loopback-only but it is still a remote console
minecraft2/rcon_password the second world's console. A separate password on purpose — one leak should not reach both worlds
minecraft3/rcon_password the third world's console, separate for the same reason
minecraft4/rcon_password the fourth world's console, separate for the same reason
serenity/bot_token, serenity/ai_api_key the Discord bot's gateway token and its DeepSeek key
searxng/secret_key signs searxng's session cookies; upstream's default is the literal ultrasecretkey
searxng/admin_user, searxng/admin_password_hash searxng has no accounts, so caddy's basic_auth is the whole access control. bcrypt, same shape as dozzle's
serenity/db_password one password, two consumers: ALTER ROLE in postgres and the pgbouncer userlist, both rendered from this key
navidrome/lastfm/api_key, navidrome/lastfm/secret one Last.fm application registration, reaching the container as ND_LASTFM_APIKEY / ND_LASTFM_SECRET. Enables scrobbling server-side; each user still links their own account under Personal Settings
renovate/token, renovate/github_com_token Renovate's bot account and its github.com rate-limit token. Host-side on purpose, so the CI runner never holds a token that can write across every repo
forgejo/system_webhooks/pages_pull/secret the HMAC the pages-hook receiver verifies. Must match the system webhook's Secret field, which is set by hand in Forgejo
syncthing/gui_password the syncthing GUI login, declared rather than inherited from the old box's config

The Actions runner's credential is not in this set. It lives on the runner box, delivered through Hetzner user-data, and that host holds no age key at all — see Secrets.

acme_email is gone from the secret set: security.acme needs it at evaluation time and a registration contact is not a credential. It is infra.acmeEmail in modules/options.nix.

Not in this repo

Restore or regenerate these. Without them a clone will not deploy.

What Where Notes
secrets.yaml committed but encrypted Template is secrets.example.yaml; the age key it is encrypted to is not here
age private key ~/.sops-nix/key.txt, and /var/lib/sops-nix/key.txt on the host Must be a recipient in .sops.yaml. Lose every recipient and the secrets are unreadable
restic password backups/restic_password in secrets.yaml The repository password is the encryption key. Lose it and every snapshot is permanently unrecoverable — keep a copy outside this repo
DKIM private key email/dkim_private_key Its public half is published from tofu/. Cannot be regenerated without republishing DNS, and a mismatch fails DKIM at every recipient
tofu/terraform.tfvars gitignored Template is tofu/terraform.tfvars.example
Mail store, forgejo repos, minecraft world docker volumes under /var/lib/docker/volumes Restore before the first boot of a rebuilt host, or the services initialise blank
Every postgres database pg_dumpall under /var/backup/postgresql, inside the same restic repository `zstd -d < all.sql.zstd \