+++ title = "CI/CD for NixOS: App Deploys with deploy-rs" date = "2026-06-08" slug = "deploying-this-blog" description = "How this blog deploys itself with Nix, deploy-rs, and Tailscale." [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.dev/site`](https://tangled.org/ankarhem.dev/site)** - the blog source. Zola static site, a Nix flake for building and deploying it. - **[`ankarhem.dev/nix-config`](https://tangled.org/ankarhem.dev/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 is irrelevant for a static-site derivation, so it's just pinned. 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 can 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"; ``` > [!NOTE] > The blog's flake already declares `inputs.self.submodules = true`. Since Nix 2.27, this is respected when the flake is consumed as an input. No need to pass `?submodules=1` on the URL. We can't use github:ankarhem/site syntax though, as that doesn't support submodules. ## 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`. 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. ## Why not just use a flake input? 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? 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. 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. ## 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. 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.dev/site`](https://tangled.org/ankarhem.dev/site) for the flake and workflow, and the [`ankarhem.dev/nix-config`](https://tangled.org/ankarhem.dev/nix-config) for the NixOS modules.