Switching Back to NixOS (Again)
09/15/2026 - ~8 Minutes
Introduction
I’ve bounced between distros plenty over the years — you can see the evidence scattered across this blog: Arch, Pop!_OS,
a couple flavors of Ubuntu, even a detour into resurrecting SunOS in QEMU. This summer I landed back on NixOS, and this time
I wanted the whole thing — desktop and laptop both — declared in one flake instead of scattered across shell history and
half-remembered pacman -S incantations. What follows is the shape that setup ended up taking, and the handful of real
hardware/software fights it took to get there.
Sharing One Flake Across Multiple Hosts
The repo started as a single machine: my System76 Pangolin laptop, originally named pango and since renamed nixie. It’s
since grown to share that flake with a second, newer machine — a GMKtec mini-PC desktop called stealth — through one
flake.nix:
mkHost = { hostname, monitors }:
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
./hosts/${hostname}/hardware-configuration.nix
./hosts/${hostname}/configuration.nix
{ networking.hostName = hostname; }
home-manager.nixosModules.default
{
home-manager.users.brian.imports = [
./home.nix
./hosts/${hostname}/home.nix
];
}
];
};
Both machines land close enough architecturally — Ryzen mobile CPUs with mobile Radeon graphics, just different generations —
that configuration.nix/home.nix at the repo root
carry the entire shared setup, and hosts/<name>/ only carries what’s genuinely tied to one physical machine — differing disk
layouts (stealth is plain btrfs; nixie is LUKS-encrypted btrfs with a separate /boot), and a couple of hardware quirks
below. The discipline of keeping hosts/<name>/configuration.nix and home.nix empty unless something actually needs to be
there turned out to matter: nixie’s home.nix had drifted into its own stale fork with different packages before I folded it
back into sharing the root file, and untangling that wasn’t fun. Once is enough.
The one thing that genuinely varies per machine and isn’t just “this belongs in hosts/<name>/” is monitor layout, so monitors
is threaded through as an actual module argument (["DP-1" "HDMI-A-1"] for the desktop’s two monitors, ["eDP-1"] for the
laptop’s single panel) rather than living in a host override file.
The other discipline that keeps two machines sharing one config sane: configuration.nix/home.nix stay minimal on purpose.
Neither is where project-specific tooling lives. A tool I reach for occasionally but don’t want permanently installed gets a
shell alias that runs it through nix run instead — htop = "nix run nixpkgs#htop", and the same for emacs, fetch,
nvtop, pyfa — so it’s available on demand without adding to home.packages or ever going stale relative to nixpkgs. And a
tool a specific project needs — terraform, hugo, awscli2, whatever — doesn’t belong here at all; it belongs in that
project’s own flake.nix, entered with nix develop. wardtalks.com’s terraform has no business being globally available
on either machine just because I happen to run it there sometimes. Keeping that boundary means this shared config only grows
when something is genuinely about the machine, not about whatever I’m currently working on.
Pin First, Ask Questions Later
Two things in this flake are pinned to older versions than nixpkgs would otherwise give me, and both are worth calling out because neither pin reflects an actual preference — they’re both working around upstream breakage:
- Hyprland is pinned to a commit before
hyprwm/Hyprland#16140
, which dropped the
numeric workspace
idfromhyprctl’s JSON output in favor of an address-based identity. Waybar’shyprland/workspacesmodule hadn’t caught up yet, and without the pin it just shows a single “0” for every workspace. linux-firmwareis pinned onnixiespecifically (not the shared config) to an older tag, because a newer release broke DMCUB firmware load on its Rembrandt/Radeon 680M GPU —[drm] *ERROR* Error queuing DMUB commandand a slow, glitchy boot.stealth’s desktop GPU isn’t affected, so the pin lives innixie’s own host overlay, not the shared file.
Both are documented right in the flake with a comment explaining exactly what to check before removing the pin — “pin and forget” is how you end up carrying a two-year-old workaround for a bug that was fixed eighteen months ago.
The Login Screen, The Hard Way
I went through two greeters before landing on one I was happy with. First was regreet under cage — cage is a single-app
kiosk compositor, and its only options for multiple monitors are “last” (pick one output) or “extend” (stitch every output
into one virtual desktop and stretch the greeter across all of it). On stealth’s two-monitor setup, “extend” meant the login
screen literally spanned both displays, which looked exactly as bad as it sounds.
I switched to SDDM via the silent-sddm flake input, using its bundled “nord” theme — plain Qt6/QML, so it doesn’t drag in
KDE Plasma Frameworks just for a login screen. It’s a real improvement, but not a total fix: SDDM’s Wayland greeter backend
only draws on the primary output on multi-monitor setups (
sddm/sddm#1696
, still open), so on
stealth the greeter still only shows on one monitor. The workaround for that would be falling back to SDDM’s X11 backend
just for the greeter — which means running Xorg on an otherwise Wayland-only system purely for login-screen cosmetics. I
decided that trade wasn’t worth it and left it alone.
Hardware Has Opinions
A few fights were specific enough to particular peripherals that they only make sense living in one host’s config, not the shared one:
- Bluetooth speakers next to the desktop needed a small systemd user service that retries connecting on a loop, because wireplumber’s A2DP audio endpoints don’t exist yet at the exact moment the Bluetooth adapter powers on at boot. It’s ordered against wireplumber specifically so it also re-fires across suspend/resume, not just at boot.
- EVE Online (a Steam/Proton game) needed a set of Hyprland window rules ordered just right: one to force the game window itself fullscreen, a separate size/position rule for its launcher window (whose self-reported dimensions reflect the pre-rule size, so the fix uses literal pixel offsets instead of relative ones), and a rule that specifically suppresses the launcher’s own delayed fullscreen request — without which the launcher and Hyprland fight over sizing it forever.
- GE-Proton gets copied into Steam’s compatibility-tools directory rather than symlinked.
home.filewithrecursive = truesymlinks each file individually, and GE-Proton’s owncopy_pfx()step preserves those symlinks when it creates a per-game wine prefix — which means the copy ends up pointing back at the read-only Nix store and crashes withEROFSthe first time a game tries to write to it. A real copy, gated behind a marker file so it doesn’t re-copy on every rebuild, sidesteps the whole problem.
None of these are NixOS-specific lessons, exactly — they’re the kind of thing you’d hit on any distro — but writing them down as Nix means they’re fixed, not “the thing I remember to do after every reinstall.”
NFS, Shared Safely Across Two Machines
Both hosts mount the same home NAS share, and it’s declared once in the shared configuration.nix. Two details make that safe:
the NAS only exports NFSv3 (confirmed with rpcinfo -p after NFSv4 mounts failed outright with “Protocol not supported”), and
the mounts are automount/idle-unmount (x-systemd.automount, a ten-minute idle timeout) rather than mounted eagerly at boot.
That second part is what actually makes sharing the config safe: only one of the two machines is ever guaranteed to be on the
home network at a given moment, and neither boot nor login blocks waiting for a NAS that might not be reachable.
Home Manager Beyond Dotfiles
The home-manager side isn’t just terminal/editor config. Neovim itself comes from a separate flake input
(
kickstart.nvim
) rather than being inlined here, git/gh are configured
declaratively (commit signing, credential helper), and — most recently — so is ~/.aws/config, via home-manager’s
programs.awscli module: one [profile] block per project I run Terraform or deploys against, generated declaratively instead
of hand-edited. package = null on that module keeps awscli2 itself out of the global package list — each project’s own
flake brings its own copy, so the actual CLI binary stays scoped to whatever repo you’re standing in. (I wrote more about the
project side of that setup — the actual AWS roles and Terraform behind it — in the
previous post
.)
Where Things Stand
A laptop and a desktop, one shared flake, and nixos-rebuild --sudo --flake .#<hostname> switch to apply changes to whichever
one I’m sitting at. Every hardware quirk that used to live in my head now lives in a comment next to the setting that compensates for it, which
is really the whole pitch of coming back to NixOS: not that it’s less work up front, but that the work you do is written
down, reproducible, and — critically — explains itself six months later when you’ve forgotten why a line of config exists at
all.
One gap that’s still open, though: disk partitioning itself isn’t declarative yet. hosts/<name>/hardware-configuration.nix is
machine-generated by nixos-generate-config after the disks are already partitioned and formatted by hand, so a fresh install
still means falling back to manual parted/LUKS commands before Nix ever gets involved. I actually tried
disko
for this — it lets you describe the entire disk layout, LUKS and all, as
Nix and have it build the disks from that description — and reverted it after less than a day. Bringing it back properly,
rather than as a same-day experiment, is next on the list; it’s the one piece of “reinstall this machine from scratch” that
still lives in muscle memory instead of the flake.
Like the last post, this one was written by Claude Code — this time working from git log in the nixos repo rather than
from a session it ran itself, to reconstruct the story of how this setup came together.
Putting This Site’s AWS Infrastructure Into Terraform
09/15/2026 - ~8 Minutes
Introduction
This site has been running on S3 and CloudFront since 2020, but until recently the actual infrastructure behind it existed
entirely in my head (and in the AWS console). Nothing was written down anywhere except a cloudFrontDistributionID in
config.toml. That finally changed: the bucket, the CloudFront distribution, the DNS zone, and even a small piece of forgotten
Lambda@Edge code are now all under Terraform, and the whole dev environment (Hugo, Terraform, the AWS CLI) is reproducible with
one nix develop. Here’s how it fits together.
The Dev Shell (flake.nix)
shell.nix was retired in favor of a proper flake. nix develop now drops you into a shell with hugo, terraform,
awscli2, and awsume:
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
git
hugo
awscli2
awsume
terraform
];
# `awsume` must be sourced (not exec'd) so it can export AWS_* env
# vars into the calling shell; this function shadows the plain
# script of the same name that's on PATH from the package above.
shellHook = ''
awsume() { source "${pkgs.awsume}/bin/awsume" "$@"; }
'';
};
That shellHook turned out to matter more than I expected. awsume is a script that assumes an AWS role and exports
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN into your current shell. If it just sits on PATH and you run
it normally, bash execs it as a subprocess — it happily assumes the role, then exits, and every one of those exported variables
evaporates with it. The fix is to shadow it with a shell function that sources the script instead of exec’ing it. Small detail,
completely broken workflow if you miss it.
terraform also needs config.allowUnfree = true in the flake, since HashiCorp relicensed it under the BSL and nixpkgs
(correctly) treats that as unfree.
One IAM Role Per Project, awsume, and Home Manager
The AWS side of this follows a convention I’ve now used across a handful of small static sites: a single aws repo owns the
human IAM users (brian, bethany), plus two shared roles —
aws-admin, for interactive/ad hoc console-equivalent work, andterraform, the identity Terraform itself assumes when runningapply, so CloudTrail can tell “a human clicking around” apart from “a Terraform run.”
Every project repo — this one included — then owns its own narrow deploy role. wardtalks.com’s is wardtalks-deploy: S3
read/write/delete on exactly this bucket, cloudfront:CreateInvalidation on exactly this distribution, and (more on this below)
enough Lambda permission to push code to exactly one function. A wardtalks-deployers IAM group grants sts:AssumeRole on that
role; membership in the group is the only thing that changes when someone new needs deploy access.
(The delete part I only got right on the second try — terraform plan showing zero drift tells you the existing
infrastructure is described correctly, it says nothing about whether a brand-new role you just wrote is missing a permission it
needs. The first real hugo deploy against this role failed with an AccessDenied on s3:DeleteObject: hugo deploy is a
two-way sync, not just an upload, so a role that can only PutObject/GetObject can push new posts but can’t prune old files
that no longer exist locally. One terraform apply later, fixed.)
~/.aws/config ties it together with one profile per role:
[profile brian]
region = us-east-2
[profile wardtalks]
source_profile = brian
role_arn = arn:aws:iam::<account-id>:role/wardtalks-deploy
[profile terraform]
source_profile = brian
role_arn = arn:aws:iam::<account-id>:role/terraform
That file is now generated by Home Manager instead of hand-edited, via the (surprisingly little-known) programs.awscli module:
programs.awscli = {
enable = true;
package = null; # awscli itself comes from each project's own flake, not globally
settings = {
"profile brian".region = "us-east-2";
"profile wardtalks" = {
source_profile = "brian";
role_arn = "arn:aws:iam::<account-id>:role/wardtalks-deploy";
};
# ...one block per project
};
};
package = null is worth calling out: it stops Home Manager from installing awscli2 as a system package, while still
generating the config file — the two are controlled independently. ~/.aws/credentials, the file with the actual long-lived
access keys, deliberately stays outside of Nix and hand-maintained, since anything in the Nix store is world-readable.
Day to day, this means awsume wardtalks gets me a shell scoped to exactly what deploying this site requires, and nothing more.
Terraform: Importing What Already Existed
The interesting part wasn’t writing new Terraform — it was writing Terraform that describes infrastructure that had existed,
untouched, since 2020, without disturbing any of it. terraform import for the S3 bucket, the CloudFront distribution and its
Origin Access Identity, and the Route53 hosted zone, followed by iterating on the .tf files until terraform plan reported
zero drift. A few things surfaced along the way that I’d genuinely forgotten:
- The CloudFront origin still points at the legacy global S3 endpoint (
wardtalks.com.s3.amazonaws.com) rather than the regional one, because the distribution predates regional endpoints becoming the default. Declaring it asaws_s3_bucket.wardtalks.bucket_regional_domain_namewould have quietly changed the origin on first apply, so it’s a literal string instead, with a comment explaining why. default_root_objecton the distribution is unset. Not “index.html” — nothing. Which raised the obvious question: how has the homepage been loading for six years?
That question is answered by the next section.
The Route53 Zone Is Shared, So Terraform Only Owns Part Of It
wardtalks.com’s hosted zone doesn’t only serve this site — it also carries records for sibling projects
(sobriety.wardtalks.com, serverless.wardtalks.com) that this repo has no business touching. So the zone itself is imported
and managed here, but only the apex A/AAAA records (aliased to this site’s CloudFront distribution) and the ACM validation
CNAME for this certificate are declared. Everything else in the zone is simply left alone — Terraform only touches resources
it’s told about, so there’s no risk of it “helpfully” pruning a sibling project’s DNS record it’s never heard of. If another
project wants its own subdomain here, the right move is for that repo to look the zone up with a data source, not for this
repo to create records on its behalf.
The Lambda Nobody Remembered
Back to that missing default_root_object. S3’s REST API endpoint — which is what CloudFront has to use for a private,
OAI-restricted bucket — has no concept of an index document at all. Not for the root, not for subdirectories. Every single page
on this site, including the homepage, depends on a small Lambda@Edge function rewriting the request URI before it ever reaches
S3: a trailing-slash request gets index.html appended, and a bare directory path like /posts/nixos gets /index.html
appended.
That function, hugo-url-rewrite, existed only as a deployed Lambda — created directly in the console back in 2020, never
committed anywhere. I pulled the actual running code back out of AWS (aws lambda get-function hands you a presigned download
URL for the deployment package) and it turned out to be a small, public snippet, credited right there in the source:
'use strict';
// @starpebble on github
// hugo flavor cafe (scotch)
const DEFAULT_OBJECT = 'index.html';
exports.handler = (event, context, callback) => {
const cfrequest = event.Records[0].cf.request;
if (cfrequest.uri.length > 0 && cfrequest.uri.charAt(cfrequest.uri.length - 1) === '/') {
cfrequest.uri += DEFAULT_OBJECT;
}
else if (!cfrequest.uri.match(/.(css|md|gif|ico|jpg|jpeg|js|png|txt|svg|woff|ttf|map|json|html|xml)$/)) {
cfrequest.uri += `/${DEFAULT_OBJECT}`;
}
callback(null, cfrequest);
return true;
};
It now lives in this repo (lambda/hugo-url-rewrite/index.js) and is deployed by Terraform, with one wrinkle: Lambda@Edge
functions must be created in us-east-1 no matter where the rest of your infrastructure lives, and CloudFront can only
reference a published, numbered version — never $LATEST. Both of those show up directly in the Terraform:
# Lambda@Edge functions must be created in us-east-1 regardless of where
# everything else lives.
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_lambda_function" "hugo_url_rewrite" {
provider = aws.us_east_1
# ...
publish = true # auto-publish a new version whenever the code changes
}
CloudFront’s lambda_function_association then points at aws_lambda_function.hugo_url_rewrite.qualified_arn, so it always
tracks whatever Terraform most recently published.
I also gave wardtalks-deploy a narrow, function-scoped grant (UpdateFunctionCode, UpdateFunctionConfiguration,
PublishVersion — deliberately not CreateFunction or DeleteFunction) so routine code pushes to this one Lambda don’t
require pulling out admin credentials. That’s a nice property of least-privilege roles once you have them: it becomes cheap to
hand out narrow new capabilities exactly where they’re needed, instead of reaching for the broad role out of convenience.
Where This Leaves Things
hugo builds the site, hugo deploy (reading the [[deployment.targets]] block already in config.toml) syncs it to S3 and
invalidates CloudFront, and the whole thing now runs under the wardtalks AWS profile with exactly the permissions that
requires — no more, no less. Every piece of infrastructure that makes that work is described in this repo, reviewable in a
terraform plan, and — should this bucket, distribution, or Lambda ever need to be rebuilt from scratch in a new account —
buildable from a clean terraform apply instead of from memory.
A note on how this post came to exist: this whole post was written by Claude Code, at the end of a long working session where it did the actual setup described above — the flake, the IAM roles, the Terraform imports, digging the Lambda’s source back out of AWS, all of it. I asked it to write up what we’d just built, and this is what came out.