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 id from hyprctl’s JSON output in favor of an address-based identity. Waybar’s hyprland/workspaces module hadn’t caught up yet, and without the pin it just shows a single “0” for every workspace.
  • linux-firmware is pinned on nixie specifically (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 command and a slow, glitchy boot. stealth’s desktop GPU isn’t affected, so the pin lives in nixie’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.file with recursive = true symlinks each file individually, and GE-Proton’s own copy_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 with EROFS the 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, and
  • terraform, the identity Terraform itself assumes when running apply, 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 as aws_s3_bucket.wardtalks.bucket_regional_domain_name would have quietly changed the origin on first apply, so it’s a literal string instead, with a comment explaining why.
  • default_root_object on 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.