dotfiles/flake.nix
Mads Fagerlund d04b17fe52
mbp: move root to btrfs on p8, reclaiming 256G from macOS
Shrinking the macOS APFS container freed ~256G, but it landed at a lower
disk offset than the Linux partitions, so the ext4 root could never be
grown into it — a partition only ever extends into free space that follows
it. Rather than relocate the Asahi stub and ESP (the reliable way to brick
the boot chain), the root moved into the gap as a new partition:

  p8  29424896..96687621  256.6G  btrfs, subvol @, compress=zstd

zstd puts ~70G of data in ~37G, and free space went from 7.3G to 220G.
The old ext4 p5 is left intact as a rollback; generations <= 48 still boot
it, since each generation carries its own root UUID in its initrd.

Also lands the pending work in this tree: postgresql in home.packages,
the deepvis cachix substituter with a root-owned netrc, nixbuild.net as a
builder for both arches, the caffeine stay-awake unit with its SUPER+A
bind, and monthly btrfs autoScrub on the new root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 14:35:28 +02:00

515 lines
21 KiB
Nix

{
description = "mads — NixOS for Apple Silicon (mbp) and x86 (pc), one flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
apple-silicon = {
url = "github:nix-community/nixos-apple-silicon";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
zen-browser = {
url = "github:0xc000022070/zen-browser-flake";
inputs.nixpkgs.follows = "nixpkgs";
inputs.home-manager.follows = "home-manager";
};
};
outputs = { self, nixpkgs, apple-silicon, home-manager, firefox-addons, zen-browser, ... }:
let
# ───────────────────── shared home-manager user ─────────────────────
home = { pkgs, osConfig, ... }: {
imports = [ zen-browser.homeModules.beta ];
home.stateVersion = "25.11";
programs.eww = {
enable = true;
package = pkgs.eww;
};
# Raw configs that stay files (no good native module).
# `hypr` is recursive so we can drop a host-specific monitors.conf in
# alongside the shared files (hyprland.conf `source`s it).
xdg.configFile = {
"hypr" = { source = ./config/hypr; recursive = true; };
"hypr/monitors.conf".text =
if osConfig.networking.hostName == "mbp"
then ''
monitor = , preferred, auto, 2.0
''
else ''
monitor = desc:Lenovo Group Limited P27q-20 V909LRFR, 2560x1440@60, 0x0, 1.0
monitor = desc:Lenovo Group Limited P27q-20 V909LPNY, 2560x1440@60, 2560x0, 1.0
monitor = desc:Lenovo Group Limited P27q-20 V909LRDD, 2560x1440@60, 5120x0, 1.0
workspace = 1, monitor:desc:Lenovo Group Limited P27q-20 V909LRFR, default:true
workspace = 2, monitor:desc:Lenovo Group Limited P27q-20 V909LPNY, default:true
workspace = 3, monitor:desc:Lenovo Group Limited P27q-20 V909LRDD, default:true
'';
"eww".source = ./config/eww;
"fuzzel".source = ./config/fuzzel;
};
programs.kitty = {
enable = true;
settings = {
font_size = "11.5";
# cyberdream theme
background = "#16181a";
foreground = "#ffffff";
cursor = "#ffffff";
cursor_text_color = "#16181a";
selection_background = "#3c4048";
selection_foreground = "#ffffff";
color0 = "#16181a";
color8 = "#3c4048";
color1 = "#ff6e5e";
color9 = "#ff6e5e";
color2 = "#5eff6c";
color10 = "#5eff6c";
color3 = "#f1ff5e";
color11 = "#f1ff5e";
color4 = "#5ea1ff";
color12 = "#5ea1ff";
color5 = "#bd5eff";
color13 = "#bd5eff";
color6 = "#5ef1ff";
color14 = "#5ef1ff";
color7 = "#ffffff";
color15 = "#ffffff";
active_tab_foreground = "#000000";
active_tab_background = "#ffbd5e";
inactive_tab_foreground = "#ffffff";
inactive_tab_background = "#16181a";
};
};
home.pointerCursor = {
enable = true;
package = pkgs.bibata-cursors;
name = "Bibata-Modern-Ice";
size = 24;
gtk.enable = true;
x11.enable = true;
hyprcursor.enable = true;
};
# Dark GTK apps (gimp, file dialogs).
gtk = {
enable = true;
theme = { name = "Adwaita-dark"; package = pkgs.gnome-themes-extra; };
gtk3.extraConfig.gtk-application-prefer-dark-theme = 1;
gtk4.extraConfig.gtk-application-prefer-dark-theme = 1;
# Adopt the new default: don't force a theme on GTK4/libadwaita apps
# (dark still applies via prefer-dark-theme above). Silences the
# stateVersion < 26.05 legacy-default warning.
gtk4.theme = null;
};
# Neovim: init.lua verbatim; plugins + LSP pinned by Nix.
programs.neovim = {
enable = true;
withPython3 = false;
withRuby = false;
withNodeJs = false;
initLua = "vim.g.nix_managed = true\n"
+ builtins.readFile ./config/nvim/init.lua;
plugins = with pkgs.vimPlugins; [
nvim-web-devicons cyberdream-nvim oil-nvim nvim-lspconfig
typst-preview-nvim blink-cmp Navigator-nvim
snacks-nvim todo-comments-nvim trouble-nvim
nvim-treesitter.withAllGrammars
fidget-nvim lualine-nvim
];
extraPackages = with pkgs; [
lua-language-server rust-analyzer tinymist nixd zls
yaml-language-server vscode-langservers-extracted
typescript-language-server svelte-language-server
];
};
# Zen browser + Tridactyl, extensions pinned (works on aarch64 + x86).
programs.zen-browser = {
enable = true;
setAsDefaultBrowser = true;
profiles.default = {
settings = {
"browser.newtabpage.enabled" = false;
"privacy.donottrackheader.enabled" = true;
"signon.rememberSignons" = false;
# Dark mode
"browser.theme.toolbar-theme" = 0;
"browser.theme.content-theme" = 0;
"layout.css.prefers-color-scheme.content-override" = 0;
"ui.systemUsesDarkTheme" = 1;
};
# Re-instantiate pkgs with the firefox-addons overlay + allowUnfree so
# the (unfree) 1Password addon evaluates under *our* config.
extensions.packages = let
addonsPkgs = import pkgs.path {
inherit (pkgs.stdenv.hostPlatform) system;
config.allowUnfree = true;
overlays = [ firefox-addons.overlays.default ];
};
in with addonsPkgs.firefox-addons; [
tridactyl
ublock-origin
onepassword-password-manager
];
};
};
# zsh + starship, no framework.
programs.zsh = {
enable = true;
autosuggestion.enable = true;
syntaxHighlighting.enable = true;
shellAliases = { ll = "ls -alh"; gs = "git status"; };
};
programs.starship.enable = true;
# SSH auth via the 1Password agent — private keys stay in the vault,
# never on disk or in the Nix store.
programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings."*".IdentityAgent = "~/.1password/agent.sock";
};
# git identity + SSH commit signing through 1Password's op-ssh-sign.
programs.git = {
enable = true;
settings = {
user.name = "Mads Fagerlund";
user.email = "mads@deepvis.ai";
user.signingkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAPlpVGZLhfrJqh2j5q3reyGOPQQ9BgW8syQ28tYjlg2";
gpg.format = "ssh";
# NixOS store path — NOT 1Password's suggested /opt/1Password/... which doesn't exist here.
"gpg \"ssh\"".program =
"${pkgs._1password-gui}/share/1password/op-ssh-sign";
commit.gpgsign = true;
# Transparently use SSH (1Password agent) for the deepvis server,
# so existing HTTPS remotes need no manual `git remote set-url`.
url."git@git.deepvis.ai:".insteadOf = "https://git.deepvis.ai/";
};
};
home.packages = with pkgs; [ ripgrep fd tridactyl-native lazygit rustc cargo clippy rustfmt rust-analyzer claude-code gcc bluetui impala postgresql ];
};
# ───────────────────── shared system (both hosts) ─────────────────────
common = { pkgs, ... }: {
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Trusted so client-side nix config takes effect — substituters and
# remote builders are trusted settings, silently ignored for anyone
# else. A cache that appears to do nothing is the usual symptom.
nix.settings.trusted-users = [ "mads" ];
# The deepvis binary cache: the embedded flakes in models/ort-nix
# (onnxruntime built from source, a CUDA training env) are hours of
# work to build and seconds to substitute.
nix.settings.extra-substituters = [ "https://deepvis.cachix.org" ];
nix.settings.extra-trusted-public-keys = [
"deepvis.cachix.org-1:UE/H6ERLwqhSqubzh8+pzl9eTm0kpMExGe0ym6fENp8="
];
# Private cache, so reads need a token. nix refuses `netrc-file` on
# the command line under the daemon, and a token in the substituter
# URL would land in the process table — a root-owned 0600 netrc is
# the only form the calling process cannot read.
nix.settings.netrc-file = "/etc/nix/netrc";
# x86_64 derivations build off this machine rather than under local
# qemu emulation, and aarch64 ones go out too when the local
# machine is busy.
#
# The host keys are pinned system-wide because the ssh'ing party is
# the root daemon, which has no known_hosts and never gets a tty to
# answer a trust prompt.
programs.ssh.knownHosts."builder" = {
extraHostNames = [ "5.9.71.104" "builder.tailf5eaab.ts.net" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHnKIrxYtlovvYGgkpEZXY8wG/CuSLMGOM/84eIlyeVW";
};
programs.ssh.knownHosts."eu.nixbuild.net" = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIQCZc54poJ8vqawd8TraNryQeJnvH1eLpIDgbiqymM";
};
nix.distributedBuilds = true;
nix.buildMachines = [
{
hostName = "builder";
sshUser = "builder";
# The daemon (root) does the ssh'ing, so it needs an explicit
# on-disk key — it has no access to a user's agent.
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "x86_64-linux";
maxJobs = 16;
supportedFeatures = [ "big-parallel" "kvm" "nixos-test" ];
}
# nixbuild.net, for both architectures. Listed after `builder` so
# the machine we already pay for is preferred.
#
# Every feature it supports is advertised, not just the two in the
# quickstart: nix only sends a derivation to a builder claiming
# *every* feature that derivation requires, so omitting `kvm` or
# `nixos-test` would silently route those back to local builds.
{
hostName = "eu.nixbuild.net";
sshUser = "root";
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "x86_64-linux";
maxJobs = 100;
supportedFeatures = [ "benchmark" "big-parallel" "ca-derivations" "kvm" "nixos-test" ];
}
{
hostName = "eu.nixbuild.net";
sshUser = "root";
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "aarch64-linux";
maxJobs = 100;
supportedFeatures = [ "benchmark" "big-parallel" "ca-derivations" "kvm" "nixos-test" ];
}
];
networking.networkmanager.enable = true;
networking.networkmanager.wifi.backend = "iwd";
# Secret Service (org.freedesktop.secrets) provider, D-Bus-activated on
# demand. Without it 1Password can't persist the 2FA device token
# ("unable to save your two-factor token") and re-prompts every unlock.
services.gnome.gnome-keyring.enable = true;
programs._1password.enable = true;
programs._1password-gui = {
enable = true;
polkitPolicyOwners = [ "mads" ];
};
# Zen (a Firefox fork) runs as "zen-beta"; 1Password only trusts an
# allowlist of browser process names, so add it or the extension can't
# unlock via the desktop app. Manifest already lives in ~/.mozilla.
environment.etc."1password/custom_allowed_browsers".text = ''
zen-beta
'';
services.tailscale.enable = true;
# Tailscale SSH: keyless, portless SSH gated by tailnet ACLs.
# Needs a matching "ssh" rule in the admin console to actually permit access.
services.tailscale.extraUpFlags = [ "--ssh" ];
time.timeZone = "Europe/Copenhagen"; # set yours
i18n.defaultLocale = "en_US.UTF-8";
programs.hyprland = { enable = true; withUWSM = true; };
programs.hyprlock.enable = true;
services.hypridle.enable = true;
# ── Amphetamine-style "stay awake" ──
# caffeine.service holds ONE logind block-inhibitor covering all three
# ways this machine can go dark:
# idle -> hypridle stops counting (no blank, no hyprlock)
# sleep -> suspend requests are refused
# handle-lid-switch -> closing the lid does nothing
# systemd owns the process, so "is it on?" is just "is the unit
# active?" -- no pidfile, and it can't be orphaned. Deliberately no
# wantedBy: it only ever starts via SUPER+A or the `caffeine` command.
systemd.user.services.caffeine = {
description = "Caffeine - inhibit idle, sleep and lid-switch";
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.systemd}/bin/systemd-inhibit --what=idle:sleep:handle-lid-switch --who=caffeine --why=stay-awake --mode=block ${pkgs.coreutils}/bin/sleep infinity";
};
};
xdg.portal = {
enable = true;
extraPortals = [ pkgs.xdg-desktop-portal-hyprland ];
};
# greetd autologins mads straight into Hyprland (via uwsm).
services.greetd = {
enable = true;
settings = {
initial_session = { user = "mads"; command = "uwsm start hyprland"; };
default_session = { user = "mads"; command = "uwsm start hyprland"; };
};
};
virtualisation.docker.enable = true;
hardware.bluetooth.enable = true;
programs.zsh.enable = true;
fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
environment.systemPackages = with pkgs; [
git kitty fuzzel gimp
wl-clipboard grim slurp swaybg brightnessctl playerctl grimblast
hyprpolkitagent xdg-utils
# Front-end for caffeine.service (above). Bound to SUPER+A.
(writeShellApplication {
name = "caffeine";
runtimeInputs = [ systemd ];
text = ''
unit=caffeine.service
is_on() { systemctl --user is-active --quiet "$unit"; }
# Guarded so `caffeine` still works over SSH / with no compositor.
toast() {
command -v hyprctl >/dev/null 2>&1 || return 0
hyprctl notify "$1" 2000 "$2" "$3" >/dev/null 2>&1 || true
}
wake_on() {
systemctl --user start "$unit"
toast 5 "rgb(5eff6c)" "Caffeine ON - idle, sleep and lid disabled"
}
wake_off() {
systemctl --user stop "$unit"
toast 1 "rgb(ff6e5e)" "Caffeine OFF - normal sleep restored"
}
case "''${1:-toggle}" in
toggle) if is_on; then wake_off; else wake_on; fi ;;
on) wake_on ;;
off) wake_off ;;
status) if is_on; then echo on; else echo off; fi ;;
*) echo "usage: caffeine [toggle|on|off|status]" >&2; exit 2 ;;
esac
'';
})
];
users.users.mads = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "docker" ];
shell = pkgs.zsh;
initialPassword = "changeme"; # CHANGE after first boot (`passwd`)
};
system.stateVersion = "25.11";
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
# Back up (don't clobber) any pre-existing files HM wants to manage.
home-manager.backupFileExtension = "hm-bak";
home-manager.users.mads = home;
};
mkHost = { system, modules }: nixpkgs.lib.nixosSystem {
inherit system;
modules = [
home-manager.nixosModules.home-manager
common
({ pkgs, ... }: {
nixpkgs.config.allowUnfree = true;
})
] ++ modules;
};
in
{
nixosConfigurations = {
# ── Apple Silicon MacBook (Asahi) ──
mbp = mkHost {
system = "aarch64-linux";
modules = [
apple-silicon.nixosModules.apple-silicon-support
./hosts/mbp/hardware-configuration.nix # generated on the Mac
({ ... }: {
networking.hostName = "mbp";
# Wi-Fi/BT firmware, vendored (a pure flake can't read /boot/asahi).
hardware.asahi.peripheralFirmwareDirectory = ./firmware;
hardware.asahi.enable = true;
boot.loader.systemd-boot.enable = true;
boot.loader.systemd-boot.configurationLimit = 3; # 500M ESP is small
boot.extraModprobeConfig = "options hid_apple fnmode=1";
boot.blacklistedKernelModules = [ "pcspkr" ];
# Root is btrfs (p8). A scrub re-reads every block and verifies its
# checksum, which is the only way bit rot on this SSD surfaces before
# something tries to read the damaged file. Metadata is DUP, so a bad
# metadata block gets repaired from its copy automatically.
services.btrfs.autoScrub = {
enable = true;
interval = "monthly";
fileSystems = [ "/" ];
};
# Build x86_64 derivations (packing-station iso/deploys) via qemu.
boot.binfmt.emulatedSystems = [ "x86_64-linux" ];
# Resolve .local names, so the packing stations can be reached and
# deployed to by hostname instead of by address.
services.avahi = {
enable = true;
nssmdns4 = true;
};
# Asahi kernel + Mesa cache, and the Raspberry Pi vendor
# kernel/firmware cache (else both compile from source).
nix.settings = {
extra-substituters = [
"https://nixos-apple-silicon.cachix.org"
"https://nixos-raspberrypi.cachix.org"
];
extra-trusted-public-keys = [
"nixos-apple-silicon.cachix.org-1:8psDu5SA5dAD7qA0zMy5UT292TxeEPzIz8VVEr2Js20="
"nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI="
];
};
})
];
};
# ── Arbitrary x86_64 PC ──
pc = mkHost {
system = "x86_64-linux";
modules = [
./hosts/pc/hardware-configuration.nix # generated on the PC
({ config, ... }: {
networking.hostName = "pc";
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true; # normal x86 UEFI
# NVIDIA (RTX 20-series or newer → open kernel module).
services.xserver.videoDrivers = [ "nvidia" ];
hardware.graphics.enable = true; # OpenGL/Vulkan userspace (was hardware.opengl)
hardware.nvidia = {
modesetting.enable = true; # required for Wayland/Hyprland
open = true; # open kernel module (Turing+)
nvidiaSettings = true;
package = config.boot.kernelPackages.nvidiaPackages.stable;
};
})
];
};
};
# `nix develop` on either arch.
devShells = nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (system: {
default = nixpkgs.legacyPackages.${system}.mkShell {
packages = [ nixpkgs.legacyPackages.${system}.nodejs ];
};
});
};
}