+++ 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 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- the blog source. Zola static site, a Nix flake for building and deploying it.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 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:
inputs.self.submodules = true;
With that set, the source tree available inside the derivation includes the submodule. The package itself is straightforward:
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 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:
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.
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:
{
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:
# 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:
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.
- 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:
- 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_IDandTS_OAUTH_SECRET- from a Tailscale OAuth client scoped totag:ciwith write access toauth_keys.BLOG_DEPLOY_SSH_KEY- the private half of the ed25519 key whose public half is committed inmodules/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 with 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.<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.
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 for the flake and workflow, and the ankarhem.dev/nix-config for the NixOS modules.