NixOS, Podman Quadlets, and OpenTofu on Hetzner Cloud

A declarative NixOS host running Podman Quadlets, provisioned with OpenTofu and deployed from GitHub Actions.

Overview

The site is a static build produced by Astro and served from a single Hetzner Cloud VM (cx33: 4 vCPU, 8 GB RAM) running NixOS. All services run as Podman containers managed through Quadlets, which are systemd units generated from declarative container definitions. The host configuration, infrastructure definitions, site sources, and CI pipelines live in one repository. No configuration is applied manually; every change reaches the server through a git push or a pull request.

Request path: Cloudflare provides DNS. Traefik is the sole ingress: it terminates TLS with Let’s Encrypt certificates obtained via ACME DNS-01 challenges against the Cloudflare API, and routes each subdomain to its backend container. The static sites are served by two nginx:alpine containers behind Traefik; nginx serves files, Traefik owns TLS and routing. The remaining services are Authelia (TOTP two-factor authentication in front of the Atlantis UI), Atlantis (OpenTofu plan/apply automation), Remark42 (comments), and CrowdSec (intrusion detection and response).

Provisioning

Provisioning is split into two OpenTofu layers:

Bootstrap (infra/bootstrap/, local state, applied manually): the VM, a persistent volume, the cloud firewall, a private network, and the initial Cloudflare DNS records. The VM boots from a custom NixOS disk image built with nixos-generators (nix build .#hetznerImage) and uploaded once with hcloud-upload-image, since Hetzner provides no official NixOS image.

Ongoing (infra/hetzner/, applied by Atlantis): subsequent infrastructure changes, primarily DNS records. Atlantis runs on the VM, receives GitHub webhooks, posts a tofu plan on each pull request, and applies on an atlantis apply comment. Its state lives on the persistent volume.

Host configuration

The NixOS configuration is deployed with Colmena. Each service is one Nix module defining a Quadlet container: image (pinned to an exact tag), volumes, network, and environment. Because Quadlets are systemd units, service management is uniform; systemctl status traefik and journalctl -u crowdsec behave the same as they would for any native service.

All stateful data is stored on the Hetzner volume, which survives VM rebuilds.

Deployment pipelines

Five GitHub Actions workflows and one webhook-driven service form the deployment system. Workflows that deploy run in a GitHub environment and never on pull_request events, so fork PRs cannot access secrets.

Dependency updates

Updates are handled by two complementary systems with deliberately disjoint scopes.

Nix flake inputs (update-flake.yml, weekly): runs nix flake update, then builds both the full NixOS system and the Colmena binary as a gate, and opens a pull request that merges automatically only if the gate passes. The colmena input is pinned to a specific revision after an unpinned update pulled in a build requiring a newer Rust toolchain than nixpkgs 24.11 provides; the gate now covers the deploy tool for the same reason.

Renovate (renovate.yml, weekly): opens pull requests for everything the flake update does not cover: container image tags in the Nix service modules (matched by a custom regex manager), the Traefik CrowdSec bouncer plugin version, GitHub Actions versions, and the sites’ npm dependencies (grouped into one PR). flake.lock is excluded from Renovate because update-flake.yml owns it and has a build gate Renovate lacks. Renovate PRs are merged manually, since merging deploys to production.

Between Renovate runs, podman-auto-update re-pulls the pinned image tags nightly, which picks up same-tag security rebuilds without changing versions.

Recovery

The design goal is that the VM is disposable. Recovery from total loss is: apply the bootstrap OpenTofu module, run colmena apply, and restore /mnt/data from a volume snapshot. Everything else (configuration, infrastructure, sites, pipelines) is reproduced from the repository.

Light