···13131414The setup involves two repositories:
15151616-- **[`ankarhem/site`](https://github.com/ankarhem/site)** — the blog source. Zola static site, a Nix flake for building and deploying it.
1717-- **[`ankarhem/nix-config`](https://github.com/ankarhem/nix-config)** — my system configuration. NixOS modules for every service running on the homelab, including the nginx vhost and the blog content itself as a flake input.
1616+- **[`ankarhem/site`](https://github.com/ankarhem/site)** - the blog source. Zola static site, a Nix flake for building and deploying it.
1717+- **[`ankarhem/nix-config`](https://github.com/ankarhem/nix-config)** - my system configuration. NixOS modules for every service running on the homelab, including the nginx vhost and the blog content itself as a flake input.
18181919The connection point is that `nix-config` pulls the blog in as a flake input, and the blog's flake knows how to deploy itself to the homelab using deploy-rs. The flake input is used to provide the initial "seed" version. Subsequent versions come from the CI/CD pipeline.
2020···49495050## deploy-rs
51515252-[deploy-rs](https://github.com/serokell/deploy-rs) is a Nix-aware deployment tool. The key idea is that it builds a *profile* — an activation script bundled with a store path — and pushes it to a remote machine. For a static site there's no service to restart, so the activation is just swapping a symlink:
5252+[deploy-rs](https://github.com/serokell/deploy-rs) is a Nix-aware deployment tool. The key idea is that it builds a *profile* - an activation script bundled with a store path - and pushes it to a remote machine. For a static site there's no service to restart, so the activation is just swapping a symlink:
53535454```nix
5555flake.deploy.nodes.homelab = {
···80808181The homelab's configuration has two small modules for this.
82828383-**`modules/deploy.nix`** — a generic module you apply to any host you want to make deployable via deploy-rs. It adds the CI key to root's authorized keys:
8383+**`modules/deploy.nix`** - a generic module you apply to any host you want to make deployable via deploy-rs. It adds the CI key to root's authorized keys:
84848585```nix
8686{
···9292}
9393```
94949595-**`modules/blog.nix`** — sets up the web root and the nginx vhost:
9595+**`modules/blog.nix`** - sets up the web root and the nginx vhost:
96969797```nix,hl_lines=7-24
9898# This is the format of a flake-parts module for NixOS.
···122122}
123123```
124124125125-The `systemd.tmpfiles` `L` rule creates the symlink at boot if it doesn't exist yet, pointing at the build that was current when the system last rebuilt. That means on a fresh deploy of nix-config, the site is immediately live with whatever version of the blog was locked in `flake.lock` at that time — before any GitHub Action has run.
125125+The `systemd.tmpfiles` `L` rule creates the symlink at boot if it doesn't exist yet, pointing at the build that was current when the system last rebuilt. That means on a fresh deploy of nix-config, the site is immediately live with whatever version of the blog was locked in `flake.lock` at that time - before any GitHub Action has run.
126126127127When deploy-rs runs later, it replaces that symlink with the new build. Next `nixos-rebuild switch` won't touch it because tmpfiles `L` only creates the symlink if it's absent.
128128···136136137137## The GitHub Actions workflow
138138139139-The workflow runs on push to `main` and on manual dispatch. It needs to reach the homelab, which isn't publicly exposed — the blog's nginx port is, but SSH isn't. The connection goes through [Tailscale](https://tailscale.com/).
139139+The workflow runs on push to `main` and on manual dispatch. It needs to reach the homelab, which isn't publicly exposed - the blog's nginx port is, but SSH isn't. The connection goes through [Tailscale](https://tailscale.com/).
140140141141```yaml
142142- name: Connect to Tailscale
···156156 run: nix develop --command deploy .#homelab.blog
157157```
158158159159-The deploy-rs CLI runs inside the flake's devShell, which pins it to the same version locked in `flake.lock`. That matters because the CLI and the `activate` lib used in the flake need to match — using a system-installed deploy-rs of a different version can cause subtle failures.
159159+The deploy-rs CLI runs inside the flake's devShell, which pins it to the same version locked in `flake.lock`. That matters because the CLI and the `activate` lib used in the flake need to match - using a system-installed deploy-rs of a different version can cause subtle failures.
160160161161The three secrets needed in the repository:
162162-- `TS_OAUTH_CLIENT_ID` and `TS_OAUTH_SECRET` — from a Tailscale OAuth client scoped to `tag:ci` with write access to `auth_keys`.
163163-- `BLOG_DEPLOY_SSH_KEY` — the private half of the ed25519 key whose public half is committed in `modules/deploy.nix`.
162162+- `TS_OAUTH_CLIENT_ID` and `TS_OAUTH_SECRET` - from a Tailscale OAuth client scoped to `tag:ci` with write access to `auth_keys`.
163163+- `BLOG_DEPLOY_SSH_KEY` - the private half of the ed25519 key whose public half is committed in `modules/deploy.nix`.
164164165165-This is a root SSH key living in GitHub secrets, which is the biggest attack-surface item in the setup. It's bounded by the fact that SSH is never publicly exposed — reaching the host at all requires being on the tailnet, and the only way onto the tailnet is the ephemeral `tag:ci` node, which exists for the duration of the job and is scoped to a single host.
165165+This is a root SSH key living in GitHub secrets, which is the biggest attack-surface item in the setup. It's bounded by the fact that SSH is never publicly exposed - reaching the host at all requires being on the tailnet, and the only way onto the tailnet is the ephemeral `tag:ci` node, which exists for the duration of the job and is scoped to a single host.
166166167167-## What actually happens on a push
167167+## Why not just use a flake input?
168168169169-1. The workflow checks out the repo with `submodules: recursive`.
170170-2. Nix is installed via `cachix/install-nix-action`.
171171-3. The runner joins the Tailscale tailnet as an ephemeral `tag:ci` node.
172172-4. SSH is configured with the deploy key.
173173-5. The workflow polls until `ssh homelab true` succeeds (usually one attempt).
174174-6. `nix develop --command deploy .#homelab.blog` runs. deploy-rs evaluates `self.packages.x86_64-linux.blog`, builds it (or fetches it from cache), copies the store path to the homelab, and runs the activation script.
175175-7. `/var/www/ankarhem.dev` is atomically repointed to the new `public/` directory.
176176-8. The Tailscale node exits and is removed from the tailnet.
169169+The blog is already a flake input in nix-config - that's how the NixOS module gets the store path for the initial symlink. So why add deploy-rs on top of it?
177170178178-The whole thing takes about 60 seconds from push to live, most of which is building Zola and the site. On a warm cache it's faster.
179179-180180-## Why this over the simple approach
181181-182182-The obvious alternative is making the blog a flake input in nix-config and letting the existing daily `auto-upgrade` service pull it. That's zero extra attack surface — no SSH key in CI, no Tailscale in the workflow, no deploy-rs machinery. The tradeoff is latency: up to 24 hours between a push and going live.
171171+The flake input is only updated when `nix-config` itself is rebuilt. My homelab runs a daily `auto-upgrade` service, so in practice a new blog post could sit up to 24 hours before going live. For a personal blog that's probably fine, but I want the same pipeline for other projects too, and for those the latency matters more.
183172184184-For a personal blog that's probably fine. I went with deploy-rs because I want the same pipeline for other projects too, and for those the latency matters more. The `deploy.nix` module is generic — adding the next project's CI key is one line. The homelab already has everything it needs.
173173+deploy-rs gives me push-to-deploy in about a minute, while the flake input still serves as the seed for fresh system deploys. The `deploy.nix` module is generic - adding the next project's CI key is one line. The homelab already has everything it needs.
185174186175## The nix-config module system
187176188188-One thing worth noting: `nix-config` uses [flake-parts](https://flake.parts/) with [import-tree](https://github.com/vic/import-tree) to auto-discover modules. Every `.nix` file under `modules/` is automatically imported. Adding `blog.nix` and `deploy.nix` to that directory is all it takes to wire them into the system — no explicit import list to update.
177177+One thing worth noting: `nix-config` uses [flake-parts](https://flake.parts/) with [import-tree](https://github.com/vic/import-tree) to auto-discover modules. Every `.nix` file under `modules/` is automatically imported. Adding `blog.nix` and `deploy.nix` to that directory is all it takes to wire them into the system.
189178190179The modules use `flake.modules.nixos.<name>` which is a flake-parts convention for defining NixOS modules that get composed into the system configuration. `blog.nix` receives `inputs` as an argument (the flake inputs, including the locked blog source), which is how it gets the store path for the `tmpfiles` symlink.
191180