diff --git a/content/posts/deploying-this-blog.md b/content/posts/deploying-this-blog.md new file mode 100644 index 0000000..2f5b67f --- /dev/null +++ b/content/posts/deploying-this-blog.md @@ -0,0 +1,202 @@ ++++ +title = "Deploying This Blog with GitHub Actions and deploy-rs" +date = "2026-06-08" +slug = "deploying-this-blog" + +[taxonomies] +tags = ["nix"] ++++ + +This blog runs on a server in my apartment. It's a NixOS homelab box that also runs Attic, Vaultwarden, Immich, and a handful of other services. When I push to `main` on the blog repository, a GitHub Actions workflow builds the Zola site as a Nix package and uses [deploy-rs](https://github.com/serokell/deploy-rs) to atomically push the output to the server. The whole thing takes about a minute. + +## The pieces + +The setup involves two repositories: + +- **[`ankarhem/site`](https://github.com/ankarhem/site)** — the blog source. Zola static site, a Nix flake for building and deploying it. +- **`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. + +The 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. + +## Building the site as a Nix package + +The blog uses the [terminus](https://github.com/pawroman/zola-theme-terminus) theme as a git submodule. Nix flakes don't include submodule contents by default, but since Nix 2.27 you can opt in with one line: + +```nix +inputs.self.submodules = true; +``` + +With that set, the source tree available inside the derivation includes the submodule. The package itself is straightforward: + +```nix +packages.blog = pkgs.stdenvNoCC.mkDerivation { + pname = "ankarhem-blog-site"; + version = "0"; + src = ./.; + nativeBuildInputs = [ pkgs.zola ]; + buildPhase = '' + runHook preBuild + mkdir -p "$out" + zola --config zola.toml build --output-dir "$out/public" + runHook postBuild + ''; + dontInstall = true; +}; +``` + +The output is a store path with a `public/` directory inside containing the rendered HTML, CSS, and assets. That path is what gets deployed. + +## deploy-rs + +[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: + +```nix +flake.deploy.nodes.homelab = { + hostname = "homelab"; + sshUser = "root"; + user = "root"; + profiles.blog = { + path = + let + p = self.packages.x86_64-linux.blog; + in + inputs.deploy-rs.lib.x86_64-linux.activate.custom p '' + ln -sfn "${p}/public" /var/www/ankarhem.dev + ''; + }; +}; +``` + +`activate.custom` wraps the store path with an arbitrary activation script. When deploy-rs runs, it copies `p` to the remote store, then runs that script as `user` (root here). The `ln -sfn` atomically repoints the symlink nginx is serving from, so there's no window where the site is missing. + +```nix +checks = lib.optionalAttrs (system == "x86_64-linux") ( + inputs.deploy-rs.lib.${system}.deployChecks self.deploy +); +``` + +## The NixOS side + +The homelab's configuration has two small modules for this. + +**`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: + +```nix +{ + flake.modules.nixos.deploy = { + users.users.root.openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3... deploy-ci@github-actions" + ]; + }; +} +``` + +**`modules/blog.nix`** — sets up the web root and the nginx vhost: + +```nix,hl_lines=7-24 +# This is the format of a flake-parts module for NixOS. +# It's mainly an abstraction so if you don't use it or are unfamiliar with flake-parts +# you safely ignore the surrounding parts and focus on the highlighted part. +{ inputs, ... }: +{ + flake.modules.nixos.blog = + { pkgs, ... }: + { + systemd.tmpfiles.rules = [ + "d /var/www 0755 root root - -" + "L /var/www/ankarhem.dev - - - - ${inputs.blog.packages.${pkgs.hostPlatform.system}.blog}/public" + ]; + + services.nginx.virtualHosts."ankarhem.dev" = { + forceSSL = true; + useACMEHost = "ankarhem.dev"; + root = "/var/www/ankarhem.dev"; + locations."/".tryFiles = "$uri $uri/ =404"; + locations."= /404.html".extraConfig = "internal;"; + extraConfig = '' + error_page 404 /404.html; + ''; + }; + }; +} +``` + +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. + +When 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. + +The blog itself is a flake input in `nix-config`: + +```nix +blog.url = "git+https://github.com/ankarhem/site?submodules=1"; +``` + +The `?submodules=1` is needed because the theme is a git submodule and nix-config needs to actually build the blog package when evaluating the module. + +## The GitHub Actions workflow + +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/). + +```yaml +- name: Connect to Tailscale + uses: tailscale/github-action@v3 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:ci +``` + +This spins up an ephemeral Tailscale node tagged `tag:ci`, joins the tailnet, and tears itself down when the job finishes. No lingering devices in the admin console. + +After joining the tailnet, the workflow writes an SSH config pointing `homelab` at the right key, waits for the host to be reachable (MagicDNS resolution and SSH can take a second after the tailnet comes up), then runs the deploy: + +```yaml +- name: Deploy blog to homelab + run: nix develop --command deploy .#homelab.blog +``` + +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. + +The three secrets needed in the repository: +- `TS_OAUTH_CLIENT_ID` and `TS_OAUTH_SECRET` — from a Tailscale OAuth client scoped to `tag:ci` with write access to `auth_keys`. +- `BLOG_DEPLOY_SSH_KEY` — the private half of the ed25519 key whose public half is committed in `modules/deploy.nix`. + +## What actually happens on a push + +1. The workflow checks out the repo with `submodules: recursive`. +2. Nix is installed via `cachix/install-nix-action`. +3. The runner joins the Tailscale tailnet as an ephemeral `tag:ci` node. +4. SSH is configured with the deploy key. +5. The workflow polls until `ssh homelab true` succeeds (usually one attempt). +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. +7. `/var/www/ankarhem.dev` is atomically repointed to the new `public/` directory. +8. The Tailscale node exits and is removed from the tailnet. + +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. + +## Why this over the simple approach + +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. + +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. + +## The nix-config module system + +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. + +The modules use `flake.modules.nixos.` 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. + +## Summary + +The full pipeline is: + +``` +push to main + → GitHub Actions + → build: nix develop --command deploy .#homelab.blog + → deploy-rs copies packages.x86_64-linux.blog to homelab store + → activation: ln -sfn .../public /var/www/ankarhem.dev + → nginx serves the new build +``` + +Both repos have the relevant code if you want to look at the full files: [`ankarhem/site`](https://github.com/ankarhem/site) for the flake and workflow, and the nix-config for the NixOS modules.