Deploying my NixOS box from GitHub Actions
Published 31 July 2026
Hello! I have a little NixOS box that runs a reverse proxy and a pile of Docker containers for me, and until last week I configured it the way everyone starts out configuring these things: by SSHing in and editing /etc/nixos/configuration.nix directly on the machine. That works fine right up until I want to know what changed, or why, or I break something and want to get back to the version that worked. So I spent an evening putting the config in git and wiring up a GitHub Actions job to deploy it. Here are some notes, including one test that confidently told me the opposite of the truth.
the config was already a git repo, which was worse than not being one
The first surprise was that /etc/nixos was already a git repo. Some past version of me had run git init in there and made nine commits. It had no remote, it was on a branch called master, and its history had nothing whatsoever in common with the repo I'd just pushed to GitHub.
That's a nastier starting point than a plain directory would have been, because the deploy script's whole job is to run git pull in that directory, and it would have failed with Not possible to fast-forward, aborting at the least convenient possible moment. I'd check for that before writing the deploy script rather than after.
the update script is deliberately boring
The thing that runs on the box is about five real lines:
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR=/etc/nixos
BRANCH=main
# sudo resets the environment, so put the system profile back on PATH
export PATH=/run/current-system/sw/bin:${PATH:-}
cd "${REPO_DIR}"
git pull --ff-only origin "${BRANCH}"
nixos-rebuild switch
Two small details in there that I didn't get right first time. The --ff-only is so that a diverged /etc/nixos fails loudly instead of getting silently clobbered — I'd much rather a deploy stop and tell me the box has drifted than have it quietly throw away a change I made in an emergency and forgot about. And the PATH line is there because sudo resets the environment, so without it nixos-rebuild isn't even on the path when the script runs.
my first deploy user was just… me
My first version just gave my own account passwordless sudo for that script. That works, but it means the key sitting in GitHub's secret store is a key to my account, and I didn't love that. So I added a github-deploy user that exists only for this. I kept the grant on my own account as well, so I can still deploy by hand without CI — which matters later on, because it's my account that the next section is about.
I deliberately kept it out of wheel and out of docker, because either of those is root-equivalent — being in docker means you can mount the host filesystem into a container and you're done. Since the generic %wheel rule can't match this user at all, the single sudo entry below is the only elevated thing it can do. Other commands aren't password-gated for it, they match no rule whatsoever:
%wheel ALL=(ALL:ALL) SETENV: ALL
joseph ALL=(ALL:ALL) NOPASSWD: /etc/nixos/update.sh ""
github-deploy ALL=(ALL:ALL) NOPASSWD: /etc/nixos/update.sh ""
The trailing "" is doing real work — it restricts the grant to that command run with no arguments. Without it you've handed over the ability to run that script with whatever arguments you like, which for a script that shells out is not nothing. The ordering caught me out too: in NixOS, security.sudo.extraRules default to mkOrder 1000 while the generic %wheel rule is 600, so this lands after it in the generated sudoers file, and sudo takes the last match.
Then the SSH key itself carries a forced command, so presenting it doesn't get you a shell at all — sshd ignores whatever the client asks for and runs the deploy instead:
command="sudo /etc/nixos/update.sh",no-pty,no-port-forwarding,
no-agent-forwarding,no-X11-forwarding,no-user-rc ssh-ed25519 AAAA...
One fiddly bit: the user still needs a real shell assigned, because sshd invokes that forced command via $SHELL -c. Setting the shell to nologin to be extra safe just breaks the deploy.
the GitHub Actions runner is a doorbell, not a delivery van
I confused myself for a while over the SSH keys, and the thing that unstuck me was realising there are two SSH connections involved and they go in opposite directions:
Connection 1 — CI opens the door Connection 2 — the box fetches the code
──────────────────────────────── ──────────────────────────────────────
GitHub runner ──SSH──▶ my box my box (root) ──SSH──▶ github.com
client: the Actions runner client: root, inside update.sh
server: my box's sshd server: github.com
account: github-deploy credential: read-only deploy key
CI never sends the code. All it does is say "run update.sh" — the forced command takes no arguments and transfers no files. The code arrives afterwards, when the script runs git pull, and at that moment the box is the client dialling out to GitHub. Because the repo is private it needs its own read-only deploy key for that, and the CI key can't help: wrong direction entirely.
Once I'd written that down as a diagram it stopped being confusing, which makes me think the confusion was never about SSH at all — I'd just been assuming that whoever starts the deploy must also be the one shipping the code. The nice side effect is that the box is self-sufficient — I can deploy by hand if Actions is down, because it holds its own credential rather than waiting to be handed one.
my test for the lockdown was broken, not the lockdown
This is the bit I actually want to tell you about. Having set all this up, I checked it by running sudo -l as my own account — the one in wheel — and it listed everything as permitted and never asked me for a password. The listing part was right, because wheel does permit everything. The missing password prompt was what worried me: that's the bit that's supposed to be locked down, and it looked like it had quietly stopped working.
It hadn't. The problem was the test. Giving the account you're asking as any passwordless entry flips sudo's default listpw=any behaviour, and under that setting sudo -l itself stops requiring authentication — so the one command I was using to check whether passwords were still required was the one command that had stopped requiring them. The lockdown was fine; I was asking a question that couldn't return a useful answer. The fix was to stop listing and start executing: actually try to run something that should be denied and see whether it is.
I think there's a whole family of security tests that break this way, where adding the thing you're testing changes the behaviour of the tool you're testing it with. I'd have happily reported that config as broken and gone looking for a bug that didn't exist.
There was a real finding hiding underneath it though, just a much smaller one: because of that same listpw=any default, my user can now enumerate their sudo rights without a password. That's information disclosure rather than privilege — they still can't run anything extra — and Defaults listpw=always closes it if you care.
I thought main was protected and it wasn't
Somewhere in the middle of this I pushed something and it went straight to main, which surprised me, because I was fairly sure I'd turned on branch protection ages ago.
I had. Branch protection just doesn't apply to anyone with admin permissions by default — on any repository, personal or not — unless you explicitly tick "Do not allow bypassing the above settings". Rulesets, the newer mechanism, work the other way round: nobody bypasses one until you put them on its bypass list. I'm the owner, so I'm an admin, so all my careful protection was politely stepping aside for the exact person most likely to push something silly at midnight. Which is, in fairness, the person it needed to stop. It matters more now than it did last month, too: main is what the deploy job watches, so anything I push straight to it goes onto the box without anyone looking at it first.
git-crypt doesn't fix the problem I actually have
There's one credential in that config — a hashed password for a service behind a reverse proxy — and I'd like it not to sit in the repo in the clear. My first thought was git-crypt: encrypt the file in git, decrypt after pulling, done.
It turns out that fixes the smaller half of the problem. git-crypt encrypts files in git, so GitHub no longer sees the plaintext, but the moment Nix reads that decrypted file as a path literal — ./secrets/whatever — its contents get copied into /nix/store, which is world-readable. So the secret leaves git and lands somewhere arguably worse.
You can dodge the copy by referring to it as a string path instead, so Nix never reads the file at build time and just bakes in the location. But now the thing standing between the secret and any user on the box is filesystem permissions, and the difference between the safe version and the leaking version is a pair of quotes, with no error either way if you get it wrong. That's the part that put me off: it fails open. If you slip, the plaintext is silently copied into the store and nothing tells you.
The two tools people actually use for this — sops-nix and agenix — work differently, and once I'd read how, git-crypt stopped looking like an option at all. The file on disk is always ciphertext, and it gets decrypted at activation time into a root-owned in-memory filesystem under /run — /run/secrets for sops-nix, /run/agenix for agenix — outside the store entirely. sops-nix even defaults to ramfs rather than tmpfs on purpose, so the plaintext can't get swapped out to disk. The same slip in that setup is harmless, because there's no plaintext lying around to copy. Fail-closed instead of fail-open.
I did look into where sops comes from before adding it to a machine I care about: Mozilla launched it in 2015 and donated it to the CNCF as a Sandbox project in 2023, and it's a general-purpose Go tool with no Nix connection, widely used in the Kubernetes world. The Nix integration on top of it is much smaller and largely one maintainer's work, which isn't a reason not to use it but is worth knowing.
things I haven't decided yet
I still haven't picked between the two, and I've been putting off the decision because the credential in question isn't very interesting and rotating it is easy. That's probably the wrong reason to delay a thing, but it's the honest one.
I also don't really know how this holds up the first time a deploy fails halfway. Right now if nixos-rebuild switch dies, I get a red tick in Actions and the box keeps running the old configuration, which is the correct behaviour and is genuinely one of the nicest things about NixOS. But I haven't tested a rollback under any pressure, and "I think it'll be fine" is not the same as knowing.
And I've no idea whether pinning the deploy to a single long-lived SSH key is something I'll regret. It's one more credential that exists forever until I remember to rotate it.
that's all for now
I did all of this in a Claude Code session, and the useful part wasn't the config-writing — it was having something patient enough to actually evaluate the NixOS config and render the sudoers file to check the rule landed where I thought it did, rather than reading it and going "yeah, looks right". The sudo -l thing came out of exactly that, and I'd have believed my broken test on my own.
If you run NixOS this way and you've solved the secrets question in a way you're happy with, I'd like to hear about it — I'm on LinkedIn.