Nix, nix-darwin, NixOS, Home Manager, and portable dotfiles
- Overview
- Supported systems
- Quick start
- Setup command
- Repository layout
- Platform behavior
- SSH, GPG, and keys
- Editor configuration
- Shell, terminal, and Git
- Development workflow
- Rollback and recovery
- Troubleshooting
This is a personal, declarative workstation configuration. Nix provides a reproducible package set, nix-darwin manages macOS, NixOS manages complete Linux systems, and standalone Home Manager supplies the same user environment on Ubuntu and other non-NixOS Linux distributions.
The repository manages, among other things:
- packages and development tools;
- Git, SSH, GPG, and signed commits;
- Bash and Zsh startup files;
- Emacs, Vim, Neovim, and tmux;
- macOS defaults, applications, and the Dock;
- NixOS hardware-independent system configuration; and
- a portable Home Manager profile for generic Linux.
The main entry point is the executable setup script in the repository root.
It detects the operating system and CPU architecture, selects the matching
flake output, and provides the same interface on every supported platform.
This repository is personalized for a private secrets repository and retains
randyburrell as the pure-evaluation fallback. When run normally, setup
detects the active macOS, NixOS, or Ubuntu account, so its username may differ.
Other personalized values still need review before using this as a generic
installer.
| Operating system | Architectures | Activation model | Scope |
|---|---|---|---|
| macOS | Apple Silicon, Intel | nix-darwin + Home Manager | Complete workstation |
| NixOS | ARM64, x86-64 | NixOS + integrated Home Manager | Complete operating system |
| Ubuntu/Linux | ARM64, x86-64 | Standalone Home Manager | User environment only |
The corresponding Nix system names are aarch64-darwin,
x86_64-darwin, aarch64-linux, and x86_64-linux. setup selects one
automatically. Intel macOS is retained for compatibility, although Nixpkgs
26.05 is the final Nixpkgs release with upstream x86_64-darwin support.
The repository can live in any directory, and setup can be called from any
working directory. Point to the checkout and activate the configuration:
DOTFILES_DIR="/path/to/your/dotfiles-checkout"
"$DOTFILES_DIR/setup" switchWhen already in the repository root, the shorter form works as usual. Evaluate first when testing a change:
./setup check
./setup build
./setup switchChoose any writable checkout location; no particular parent directory or folder name is required:
DOTFILES_DIR="$HOME/dotfiles"
git clone git@github.com:randy1burrell/dotfiles.git "$DOTFILES_DIR"
"$DOTFILES_DIR/setup" switchChange DOTFILES_DIR to any destination. The script finds nix/flake.nix
relative to its own resolved location, so it does not depend on the current
working directory and can also be launched through a symlink.
On macOS and generic Linux, switch installs upstream Nix with the official
NixOS installer when Nix is missing. curl, Git, and administrator access
must already be available. On NixOS, Nix is part of the operating system and
setup will not attempt to replace it.
Before installing on macOS, setup repairs /etc/synthetic.conf to contain
one exact nix mount-point entry while preserving unrelated entries. It saves
the previous file under /var/backups, asks macOS to create the mount point,
and retries the installer once if the installer's own cleanup removes the
entry. This handles the common failed to configure synthetic.conf failure
without requiring manual file edits.
Nix installers have used both 350 and 30000 for the macOS nixbld group.
Before evaluating nix-darwin, setup reads the existing group and selects the
matching declarative Darwin configuration automatically. Do not change the
group with macOS user-management commands.
Run ./setup install-nix explicitly to install upstream Nix or replace an
existing receipt-managed Nix installation. Replacement is intentionally
guarded: it requires typing reinstall, removes nix-darwin first, and then
removes the Nix store, installed packages, and saved system generations. It
finishes with upstream Nix only; nix-darwin is not restored by this action.
Running ./setup switch later will install nix-darwin again because the macOS
system configuration in this repository declares it.
On a machine that cannot clone the repository yet, download the standalone bootstrap script over HTTPS. Review it before running it rather than piping a network response directly into a shell:
BOOTSTRAP_URL="https://raw.githubusercontent.com/randy1burrell/dotfiles/main/bootstrap-gpg-ssh"
curl -fsSLo /tmp/bootstrap-gpg-ssh "$BOOTSTRAP_URL"
less /tmp/bootstrap-gpg-ssh
chmod 700 /tmp/bootstrap-gpg-ssh
/tmp/bootstrap-gpg-sshThe script supports macOS, Linux, and Windows through WSL. Bash, Git, GnuPG,
and OpenSSH must already be installed; the script deliberately does not invoke
an operating-system package manager. It enables the GnuPG SSH agent, creates a
small OpenSSH include, preserves ordinary existing configuration with
timestamped backups, and waits for a YubiKey so it can show the available SSH
public key. Conventional ~/.ssh/id_ed25519 and ~/.ssh/id_rsa identities
remain automatic fallbacks during the migration.
Register the displayed public key with GitHub, then clone the dotfiles repository over SSH and activate it:
DOTFILES_DIR="$HOME/dotfiles"
ssh -T git@github.com
git clone git@github.com:randy1burrell/dotfiles.git "$DOTFILES_DIR"
"$DOTFILES_DIR/setup" switchUse /tmp/bootstrap-gpg-ssh --no-wait to install the configuration when no
YubiKey is present. Native Windows is not supported because this setup relies
on a Unix-domain agent socket; run it and Git inside WSL instead. The YubiKey
must already be attached to that WSL distribution through USB pass-through.
If a nix-darwin uninstall was interrupted but the installer-owned Nix profile
is still usable, install-nix offers a non-destructive recover path instead.
Recovery preserves the Nix store, saves removed system artifacts under
/var/backups, restores the upstream daemon and shell files, and removes only
verified nix-darwin links and services.
The flake has a private secrets input fetched from GitHub over SSH. A new
machine must have an SSH identity with access to that repository before the
flake can evaluate. The private key and decrypted secrets must never be
committed to this public repository.
Insert the YubiKey containing the OpenPGP authentication key and register that card before the first build. Repeat the registration action once per card:
./setup github-ssh-key
ssh -T git@github.comGitHub reports that it does not provide shell access even when authentication succeeds; that message is expected.
Running ./setup without arguments opens a menu. After an action finishes,
the menu appears again. Select quit when finished.
./setupThe first setup run asks for the GitHub username and saves it privately in
~/.config/dotfiles/github-user. Later runs reuse it without prompting. Use
the github-user menu item or the direct command below whenever it changes.
Every menu action is also available directly, which is preferable in scripts and when copying diagnostic output.
| Command | Behavior |
|---|---|
./setup check |
Evaluates the selected configuration without building it. |
./setup build |
Builds the configuration without activating it. |
./setup switch |
Builds and activates the selected configuration. |
./setup apply |
Shows the dynamically resolved account and machine values without rewriting source files; on generic Linux it is equivalent to switch. |
./setup apt |
On Ubuntu, installs or upgrades the APT packages declared by Nix without removing other packages. |
./setup snaps |
On Ubuntu, ensures snapd is installed and installs or refreshes the Snaps declared by Nix. |
./setup github-user [USERNAME] |
Shows the current username in an interactive prompt or saves a replacement. |
./setup github-ssh-key |
Registers the connected YubiKey's OpenPGP authentication key with the saved GitHub account. It loads gh through Nix and opens login when needed. |
./setup doctor |
Validates every maintained script and Nix source, tangles and parses Emacs configuration, and checks GnuPG, tree-sitter, and GitHub SSH readiness. Use --static to skip machine-specific checks. |
./setup clean |
Deletes generations older than seven days and optimizes the applicable Nix store/profile. |
./setup install-nix |
Installs upstream Nix or offers a guarded replacement that leaves nix-darwin uninstalled. |
./setup update-secrets |
Encrypts and pushes local secrets plus the durable files under ~/.ssh, then refreshes their lock entry. |
./setup pull-secrets |
Downloads, validates, backs up, and restores the encrypted SSH secrets into ~/.ssh. |
./setup refresh-secrets-lock |
Refreshes only the private secrets entry in flake.lock. |
./setup gpg |
Starts and verifies the GPG-backed SSH agent. |
./setup check-keys |
Checks the expected SSH and Agenix key files and the agent socket. |
./setup copy-keys |
Copies a complete key set from a mounted drive. |
./setup create-keys |
Interactively creates the GitHub and Agenix ED25519 key pairs. |
./setup yubikey-login |
Adds the connected YubiKey as a local computer-login method while preserving password recovery. |
./setup yubikey-agenix |
Provisions or imports a hardware-backed Age identity and adds it to the Agenix recipients. |
./setup yubikey-setup |
Guides one connected card through PIN setup, distinct on-card OpenPGP keys, local login, and hardware-backed Agenix enrollment. |
./yubikey-reset [serial] |
Irreversibly clears every resettable application on exactly one connected YubiKey without confirmation prompts. |
The command-line parser passes action-specific flags to the selected helper;
for example, ./setup yubikey-setup --status. Key commands also use the
documented environment variables and protected interactive prompts.
Connect exactly one card, then run:
./setup yubikey-setupRun the workflow separately for each YubiKey. It detects the serial number, refuses to continue when another card could be selected accidentally, and guides these independent application setups:
| YubiKey application | Configuration |
|---|---|
| OpenPGP | Changes the User and Admin PINs, configures a separate Reset Code, opens GnuPG's on-card generator for distinct signing/encryption/authentication keys, enables optional touch, exports the public OpenPGP and SSH keys, and can retain the signing PIN until card removal. |
| PIV | Changes the PIN and PUK, converts the management key to a random PIN-protected value, preserves or creates the slot 9a login certificate, and gives age-plugin-yubikey a retired slot for Agenix. |
| FIDO2 | Sets or changes the PIN used when Linux or a supported Windows configuration enrolls the card for local login. |
The suggested two-value plan uses an eight-digit everyday PIN for the OpenPGP User PIN, PIV PIN, and FIDO2 PIN, and a separate admin/recovery value for the OpenPGP Admin PIN and PIV PUK. These applications do not actually share PIN storage: the official prompt for each application must receive the intended value. Use a unique OpenPGP Reset Code and keep it with offline recovery material; it can unblock the OpenPGP User PIN but cannot recover a blocked Admin PIN. The random PIV management key is another credential stored on the card under PIN protection. The script never reads, echoes, stores, or supplies any PIN, PUK, or Reset Code as a command-line argument.
The default workflow is resumable and preserves populated key slots. Inspect a card without changing it with:
./setup yubikey-setup --statusTo irreversibly clear every resettable application without a confirmation prompt, connect exactly one card immediately before running:
./yubikey-reset
./yubikey-reset 31379516 # optional serial checkThe FIDO reset still requires touching the flashing card, as required by the
YubiKey itself. A regular YubiKey 5 has no universal factory-reset command, so
the helper resets FIDO, OATH, OpenPGP, PIV, YubiHSM Auth, Security Domain, and
both OTP slots separately. It cannot recreate the unique Yubico OTP credential
originally installed in slot 1 at the factory. Run ./yubikey-reset --help
beforehand for the complete data-loss scope and optional access-code variables.
Generating a new OpenPGP identity on an already populated card requires the
explicit --reset-openpgp option and the typed phrase containing its serial
number. --reset-piv has the same guard but erases the macOS login certificate
and every PIV/Agenix private key on that card. Neither option resets FIDO2, so
existing passkeys are not erased. Public pre-reset inventories and final
public keys are stored under
~/.local/state/dotfiles/yubikeys/<serial>/; no private key, PIN, PUK, Reset
Code, or management key is written there.
./setup yubikey-setup --reset-openpgp
./setup yubikey-setup --reset-piv --reset-openpgpThe on-card OpenPGP and Agenix keys are intentionally non-exportable. Keep the
file-backed Agenix recovery identity, authorize every card's exported SSH key
on GitHub and each server, and publish/register every OpenPGP public key used
for commit signing. After all cards are enrolled, run ./setup update-secrets
once to re-encrypt the Agenix files to all saved recipients. A lost card cannot
be cloned; provision a replacement with a new key, authorize its public
material, re-encrypt to the new recipient set, and revoke/remove the lost
card's public credentials.
The managed Git configuration does not hard-code one signing fingerprint. During a commit it reads the signing fingerprint from the connected OpenPGP card and substitutes it for Git's requested signer; verification and other GPG operations pass through unchanged. This lets any separately provisioned card sign after its public key is present in the local keyring and registered with the relevant Git host. Keep only the card you intend to use connected when signing.
Emacs likewise avoids a single-card encryption key. ./setup gpg maintains
~/.gnupg/openpgp-encryption-recipients from the encryption-capable public
certificates published by the saved GitHub account, while yubikey-setup adds
a newly provisioned card immediately. EasyPG files and ordinary Org crypt
entries are encrypted to every fingerprint in that allowlist, so any one card
can decrypt newly saved content. A heading-specific CRYPTKEY property remains
an intentional override. Expired, revoked, or disabled encryption subkeys are
excluded from new ciphertext even if their historical fingerprint remains in
the file.
Existing ciphertext cannot gain another recipient without first being
decrypted. Open an older .gpg file once using the exact YubiKey it requests,
then save it; Emacs replaces its historical recipient list with the complete
allowlist. For embedded Org crypt entries, decrypt the entries and save the
Org file. Afterward, verify decryption separately with each card.
For the highest assurance, provision on a clean offline machine after the required tools are installed. On-card generation prevents private-key export, but an offline environment also reduces the risk that a compromised host changes the requested identity or key policy. A fully updated trusted everyday computer is a reasonable operational choice when that stronger threat model is not required.
Run the login action once with each YubiKey connected. Each key is enrolled independently, so any enrolled key can be used at the login screen:
./setup yubikey-login
./setup yubikey-login --status
./setup yubikey-login --disableThe action deliberately keeps the normal account password enabled as a recovery method and refuses configurations that would require key-only login. The PIN and registration mechanism differ by operating system:
| Platform | Login method and limitation |
|---|---|
| macOS | Verifies that the certificate in PIV authentication slot 9a matches its private key, safely repairs a mismatched self-signed certificate, and pairs it with the current account. FileVault pre-boot unlock still uses the account password. |
| Linux | Enrols the key with pam_u2f, requires its FIDO2 PIN, stores mappings in root-owned /etc/u2f_mappings, and adds the module as sufficient so password login remains available. NixOS applies the PAM portion declaratively during switch. |
| Windows | Opens Microsoft's protected security-key registration pages. Native FIDO2 PIN sign-in is available only on Microsoft Entra joined or hybrid joined computers whose administrator enables security-key sign-in; local accounts and personal Microsoft accounts are not supported. Run yubikey-login-windows.ps1 directly from PowerShell when Bash is unavailable. |
On macOS, OSStatus error -67808 or EC signature verification failed, no match means the identity selected by macOS could not verify a signature from
the PIV slot 9a private key. The login helper attests on-card keys before
pairing, calculates macOS's SHA-1 hash of the verified raw public key, and pairs
only that exact identity. It never selects an unrelated hash merely because it
is the only one listed. If macOS is still exposing an old certificate, the
helper asks you to remove and reinsert the card and waits for CryptoTokenKit to
publish the current identity.
If the helper finds a certificate/private-key mismatch, it backs up the old
certificate, offers to replace only that certificate, and preserves the private
key. Remove and reinsert the YubiKey after repair, then run
./setup yubikey-login again. If slot 9a has an always or cached touch
policy, touch the key after entering the PIV PIN during pairing and login.
The OpenPGP, PIV, and FIDO2 applets have separate PINs unless you explicitly set them to the same value. A passwordless Linux desktop login may also leave GNOME Keyring or KDE Wallet locked until the account password is entered.
The apply action mutates template values in the checkout on macOS and NixOS.
Review or commit the working tree before using it. On NixOS it asks for a
hostname, network interface, and disk name; selecting a disk only writes the
template value, but a later Disko installation can erase that disk.
The clean action is destructive by design. Once old generations are garbage
collected, they can no longer be used for rollback.
| Variable | Purpose |
|---|---|
GITHUB_USER |
Supplies the username during an unattended first run; the saved preference takes precedence afterward. |
SYSTEM_CONFIG |
Overrides the architecture-derived flake configuration name. |
KEYS_MOUNT_PATH |
Selects the directory used by copy-keys instead of automatic removable-media discovery. |
SECRETS_SSH_IDENTITY |
Selects the SSH key used to clone and push the private secrets repository. |
AGENIX_IDENTITY_PATH |
Selects the private key used to decrypt secrets instead of ~/.ssh/id_ed25519_agenix. |
AGE_YUBIKEY_IDENTITY_PATH |
Overrides ~/.config/age/yubikey-identities.txt. |
AGE_YUBIKEY_RECIPIENTS_PATH |
Overrides ~/.config/age/yubikey-recipients.txt. |
SECRETS_CONFIRM=yes |
Permits secrets push or installation without an interactive confirmation. |
Example:
KEYS_MOUNT_PATH=/Volumes/Keys ./setup copy-keys.
├── setup # Cross-platform command and interactive menu
├── bootstrap-gpg-ssh # Pre-Nix GPG/YubiKey SSH bootstrap
├── yubikey-setup # Guarded complete per-card provisioning
├── yubikey-reset # Non-interactive destructive card reset
├── yubikey-login # Local account login enrolment
├── yubikey-agenix # Hardware-backed Agenix enrolment
├── README.md # This operator's guide
├── configs/ # Historical/source configurations retained during migration
└── nix/
├── flake.nix # Inputs, outputs, target systems, and flake apps
├── flake.lock # Reproducible dependency revisions
├── apps/
│ ├── shared/ # Shared GPG and key implementations
│ └── <system>/ # Architecture-specific app entry points
├── hosts/
│ ├── darwin/ # macOS system configuration
│ └── nixos/ # NixOS system configuration
├── modules/
│ ├── darwin/ # macOS packages, Homebrew, Dock, and user setup
│ ├── linux/ # Standalone Home Manager for generic Linux
│ ├── nixos/ # NixOS desktop, disk, files, and services
│ └── shared/ # Portable packages and program configuration
└── overlays/ # Local Nixpkgs extensions
Portable behavior belongs in modules/shared. A platform module should only
contain behavior that cannot be expressed portably. Generic Linux imports the
shared shell, editor, Git, SSH, GPG, package, and file configuration while
deliberately omitting NixOS-only desktop and system services.
The darwinConfigurations outputs manage system packages, Home Manager,
launchd agents, macOS preferences, Homebrew formulae/casks, and applications in
/Applications/Nix Apps. The Emacs daemon is started by launchd and new GUI
frames should normally be opened with:
emacsclient -cEach macOS switch updates and upgrades the declared Homebrew bundle and removes unlisted Homebrew formulae and casks without prompting. Mac App Store apps are excluded from cleanup because Homebrew cannot distinguish apps installed by the configuration from apps installed independently through the App Store or MDM.
switch builds the nix-darwin system before running darwin-rebuild with
administrator privileges. Homebrew failures are reported as part of the
activation output and do not disappear merely because the Nix build succeeded.
The build uses the username reported by id -un and the current absolute
HOME; run setup as the normal macOS user rather than with sudo.
The nixosConfigurations outputs manage the complete operating system and use
Disko for the declared partition layout. A pristine checkout contains
%HOST%, %INTERFACE%, and %DISK% placeholders. Personalize and review
them before attempting an installation:
./setup apply
git diff -- nix/hosts/nixos nix/modules/nixosDo not run a Disko installation until the resolved device path has been checked
against lsblk. Disko can repartition and erase the configured disk.
On an installed NixOS host, ./setup switch obtains the current username from
id -un, uses the current absolute HOME, and delegates to
nixos-rebuild switch. It preserves that identity and SSH_AUTH_SOCK through
administrator elevation so root evaluates the same account and can fetch the
private flake input. Run setup as the normal NixOS user, never with sudo.
Non-NixOS Linux uses homeConfigurations.<system>.activationPackage. It
manages the user profile and does not replace Ubuntu, repartition disks, or
enable distribution-level services.
setup obtains the standalone Home Manager username from id -un and uses the
current absolute HOME; run it as the normal desktop user, never with sudo.
This permits the same checkout to activate accounts such as grundy-ubuntu
without editing the repository.
The profile includes the shared packages, shell configuration, Git, SSH/GPG, Emacs, Vim/Neovim, tmux, fonts, and selected user services. On an Ubuntu GNOME desktop it also applies a macOS-inspired WhiteSur dark theme and cursor, a compact always-visible bottom dock, left-side window controls, natural scrolling, and Command-like Super-key window shortcuts. Press Super by itself for GNOME's overview search, Super+Return for Alacritty, Super+W to close a window, Super+M to minimize, and Super+Tab to switch applications. Log out and back in after the first activation so the complete desktop theme and cursor are reloaded.
The Ubuntu profile installs an Emacs desktop entry directly in
~/.local/share/applications, so searching for Emacs in GNOME opens an
emacsclient frame. The managed daemon starts with the user session so the
first frame does not wait for the complete configuration.
Ubuntu host packages are declared separately from the Home Manager profile.
Edit nix/modules/linux/apt-packages.nix for distribution-owned APT
packages and nix/modules/linux/snaps.nix for Snap applications. The
APT list intentionally contains only host integration needed by this setup:
Linuxbrew prerequisites, smart-card/YubiKey support, and snapd. Ordinary
command-line and development tools should remain in the shared Nix package
list whenever possible.
After Home Manager activates, every Ubuntu ./setup switch updates APT
metadata, installs or upgrades the declared APT packages, installs or refreshes
the declared Snap applications on their selected channels, and then reconciles
Linuxbrew. These system operations request sudo; run setup as the normal
desktop user rather than as root. Use ./setup apt or ./setup snaps to run
the respective reconciliation independently. The snaps action also runs the
APT reconciliation first so a new machine receives snapd before Snap is
used. /snap/bin is added to the managed login path.
APT and Snap remain Ubuntu-owned mutable package systems. Nix generates their
desired manifests, but their files are not part of a Nix generation and cannot
be rolled back with Home Manager. Reconciliation never removes undeclared APT
packages or Snaps, so software installed manually by the user remains intact.
./setup clean clears the downloaded APT package cache but deliberately avoids
apt autoremove and does not delete Snap revisions.
The committed Snap list uses upstream or verified publishers. Community and unofficial repacks are not installed unattended; add one explicitly only after reviewing its current Snap Store publisher and confinement.
Ubuntu also uses Homebrew on Linux for the portable formulae in the macOS
Homebrew list. The first ./setup switch installs the supported Linuxbrew
prefix and its Ubuntu build prerequisites when needed. Every switch then runs
Homebrew update, installs and upgrades the shared formulae, removes unmanaged
formulae to match the declared bundle, and cleans old versions. It also checks
the macOS cask list and includes any cask that Homebrew reports as supporting
the current Linux architecture. Casks mapped to the declared Ubuntu Snaps are
excluded to prevent duplicate applications. The macOS-only mas formula,
incompatible and unmapped casks, and Mac App Store applications remain
macOS-only. GnuPG, GPGME, and Pinentry are excluded from Homebrew on every
platform because Home Manager owns that complete security-sensitive toolchain
and its agents. Both Bash and Zsh load Linuxbrew's shell environment and
completions, and ./setup clean prunes old Linuxbrew files alongside Nix
generations. Homebrew's own public GitHub repositories always use HTTPS during
these operations, independently of the personal Git URL rewrites and
GPG-backed SSH agent configuration.
Home Manager writes user-level GNOME settings only. Facilities such as the Docker daemon, system firewall, display manager, GNOME installation, and kernel configuration remain the responsibility of the host distribution. On a non-GNOME Linux desktop, the installed GTK/Qt theme still applies but the GNOME dock and window-manager preferences have no effect.
The shared Java module selects Zulu as its only JDK and exports the matching
JAVA_HOME. Do not add a second OpenJDK package to home.packages; two JDKs
both expose lib/src.zip, which makes Home Manager's profile build fail.
When activation encounters an unmanaged file at a Home Manager destination,
setup uses the backup extension .bk rather than silently overwriting it.
Home Manager starts gpg-agent with SSH support on every platform. Bash and
Zsh export the agent's SSH socket, and macOS also publishes it to launchd so GUI
applications can inherit the same value.
Before YubiKey provisioning, setup selects Home Manager's complete GnuPG suite
and verifies that the process behind the agent socket is the same version. On
Ubuntu it reloads the user systemd units before replacing a stale distribution
agent, which also ensures the matching scdaemon is launched. This prevents a
newer Nix gpg client from failing card discovery against an older Ubuntu
gpg-agent or scdaemon.
The agent keeps an actively used GPG or SSH credential cached for four hours, with an eight-hour hard limit from the initial PIN/passphrase entry. Restarting the agent, logging out, or rebooting clears the agent cache. GnuPG resets the four-hour idle timer whenever the cached credential is used, but the hard limit still applies.
An OpenPGP YubiKey has a separate signature policy. Its factory-style
forced policy requests the PIN for every commit signature and is not governed
by the agent's time limits. To let a card retain its signature PIN, connect one
YubiKey at a time and run:
./setup cache-gpg-pinConfirm the persistent card change and enter the Admin PIN if Pinentry asks. Repeat for all three YubiKeys. OpenPGP cards do not provide an hour-based signature-PIN timer: after this change, the PIN remains valid on the card until it is unplugged, powered down, reset, or switched away from the OpenPGP applet. Unplug the key whenever you want to lock it immediately. Any configured physical-touch requirement still applies to every operation.
The expected key files are:
| File | Purpose |
|---|---|
~/.ssh/id_ed25519 |
Transitional SSH fallback and Agenix compatibility identity. |
~/.ssh/id_ed25519.pub |
Public half of the transitional identity. |
~/.ssh/id_ed25519_agenix |
Agenix file-encryption identity. |
~/.ssh/id_ed25519_agenix.pub |
Public half of the Agenix identity. |
Private keys are mode 0600 and the SSH directory is mode 0700. Key scripts
must be run as the normal user, never with sudo.
The private secrets input deploys id_rsa, authorized_keys, and
config_external into ~/.ssh on macOS, NixOS, and Ubuntu. Agenix decrypts
them with ~/.ssh/id_ed25519_agenix, with ~/.ssh/id_ed25519 retained as a
compatibility identity. macOS and NixOS decrypt during system activation,
while Ubuntu uses the standalone Home Manager activation.
The shared Git configuration rewrites GitHub, GitLab, and Bitbucket HTTPS URLs
to SSH. Every host can use authentication identities exposed through
gpg-agent. During the YubiKey migration, OpenSSH also retains its conventional
default identity files, including ~/.ssh/id_ed25519 and ~/.ssh/id_rsa, as
automatic fallbacks. ./setup gpg refreshes connected readers before
displaying the agent identities.
The private secrets flake input and the managed Purcell Emacs checkout also
use Git over SSH.
Give each YubiKey its own OpenPGP authentication subkey and authorize all of their SSH public keys on GitHub and every server. This gives all three tokens the same access without sharing private key material, and a lost token can be removed without rotating the other two.
Connect one YubiKey at a time and run:
./setup gpg
ssh-add -LRecord the reported public key with the token's label or serial number, then add that public key to each destination. Repeat for all three YubiKeys. GnuPG prioritizes authentication keys from connected cards, so an authorized active card is selected before any other agent key. If a destination has only an old card's public key, it cannot accept a replacement card; authorize the new public key before removing the old one. The file-backed SSH identities remain available until this registration work is complete.
This section applies to SSH authentication. OpenPGP signing keys and Agenix recipients have their own selection rules and are migrated separately.
Connect one YubiKey at a time and run the following command once for each of the three cards:
./setup yubikey-agenixIf the card already contains an identity created by age-plugin-yubikey, the
script imports its public locator. Otherwise it generates a non-exportable
P-256 private key in the first empty retired PIV slot. It never passes
--force, so an occupied slot is not overwritten. The default policy requests
the PIV PIN once per card session and requires physical touch for every
decryption. The script then performs an encryption/decryption test and offers
to re-encrypt and push the private secrets repository. This requires a
PIV-capable YubiKey 4 or 5; the blue Security Key series has no PIV applet and
cannot store this identity.
Every enrolled card has a different private key. update-secrets encrypts each
secret to all enrolled public recipients, so any one of the three cards can
decrypt it. The private keys cannot be copied from one card to another. To
replace a lost card, provision the replacement and re-encrypt the secrets for
the remaining and replacement recipients.
The script writes only public material to:
| File | Purpose |
|---|---|
~/.config/age/yubikey-identities.txt |
Public locators that tell Age which card and retired slot contain each non-exportable private key. |
~/.config/age/yubikey-recipients.txt |
Public recipients added to every encrypted secret. |
The dedicated ~/.ssh/id_ed25519_agenix recipient remains enabled as a
file-backed recovery path. Keep an additional protected copy somewhere separate
from the computers and YubiKeys. Removing that recovery recipient would make
the cards the only means of decrypting the data and is intentionally not
automated.
The Age identity uses the YubiKey PIV applet and therefore its PIV PIN. It is separate from the OpenPGP PIN used for Git/SSH and the FIDO2 PIN used for Linux or Windows login. The retired Age slot does not replace the macOS authentication certificate in PIV slot 9a.
Do not guess the PIV PIN: a wrong value consumes one of its limited retry
attempts. If provisioning reports Custom unprotected non-TDES management keys are not supported, inspect and repair only the management key with:
./setup yubikey-agenix --repair-management-key
./setup yubikey-agenixThe repair first displays the card's read-only PIV status, including its
remaining PIN attempts, and asks for confirmation. If the output has neither a
Using default Management key warning nor a protected by PIN message, the
card has a custom unprotected management key. Locate the saved current key
before continuing; for TDES it is 48 hexadecimal characters, is separate from
every PIN and PUK, and leaving the prompt blank will fail. The management key
cannot be recovered from the card.
The repair converts that key to a random PIN-protected TDES key but does not
change the PIV PIN, reset the PIV applet, or erase certificates and private keys
such as the macOS login credential in slot 9a. Cancel if the current management
key is unknown. Never use ykman piv reset as a workaround because that erases
every PIV slot.
Useful checks are:
./setup yubikey-agenix --status
./setup yubikey-agenix --test
./setup yubikey-agenix --enroll-onlyOn Ubuntu, the setup wrapper selects the managed GnuPG suite and releases
scdaemon before each age-plugin-yubikey operation. This is also done after
the private repository has been cloned through GPG-backed SSH, because that
authentication can otherwise leave the smart-card reader exclusively owned by
GnuPG and make Age incorrectly ask for an already-connected YubiKey.
./setup gpg creates any missing GPG configuration files, enables SSH support,
reloads the agent, publishes its socket, and reports the visible identities.
./setup create-keys creates unencrypted ED25519 key files after confirmation.
It never replaces an existing key without asking first.
./setup copy-keys searches common macOS and Linux mount locations for a
complete key set. Use KEYS_MOUNT_PATH when more than one mounted source is
available or automatic discovery is inappropriate.
./setup check-keys verifies all four files and confirms that the GPG-agent SSH
socket is usable.
./setup update-secrets encrypts id_rsa, authorized_keys, and
config_external before placing them in the private repository. It also
automatically creates and encrypts ssh-directory.tar.gz.age, containing all
durable regular files under ~/.ssh. Plaintext is never added to Git. The
archive excludes the recovery identities needed to decrypt it
(id_ed25519[_agenix]), Home Manager's generated config link, agent/control
sockets, editor swap files, and .bk* artifacts. Every push synchronizes
secrets.nix recipient rules for all four encrypted artifacts using the
dedicated recovery identity, default ED25519 identity, and all enrolled
YubiKeys. The action shows the pending Git files and requires confirmation
before committing and pushing. If live ~/.ssh/config_external is absent, the
retained repository source is used only as bootstrap input to encryption.
./setup pull-secrets clones the private repository, decrypts and validates
the individual secrets and optional SSH directory archive in a temporary
directory, and asks before restoring them into ~/.ssh. Before merging an
archive, it saves the current durable SSH files under
~/.local/state/dotfiles/ssh-backups; files that exist only on the destination
machine are not deleted. Older private repositories without the archive remain
compatible and receive the original three-file restore behavior. Decryption
can use the dedicated ~/.ssh/id_ed25519_agenix key, the transitional default
SSH key, or an enrolled YubiKey. The file-backed recovery keys are deliberately
not stored inside the encrypted bundle that depends on them.
When several YubiKey Age locators are enrolled, pull-secrets asks
age-plugin-yubikey which identities are visible on the currently connected
hardware and creates a temporary filtered identity file. Age therefore never
prompts for an absent card merely because its serial appears first in the
persistent locator list. If no connected card matches and no file-backed
recovery identity is available, the action stops with a diagnostic before
decryption instead of entering the plugin's serial-by-serial prompt loop.
Git commits are signed by default with the configured OpenPGP key. Git LFS,
the main default branch, rebase-on-pull, automatic rebase stashing, and
automatic upstream creation are also declared through Home Manager.
Home Manager keeps ~/.emacs.d as a Git checkout of the public
https://github.com/purcell/emacs.d.git repository. A missing checkout is
cloned from main over HTTPS without requiring a YubiKey. An existing checkout
is handled conservatively:
- local changes are preserved and no update is attempted;
- a checkout on a branch other than
mainis preserved; - a clean
mainbranch is fast-forwarded when possible; - an ahead or diverged
mainbranch is preserved; and - a temporary fetch failure does not invalidate an existing checkout.
The personal literate configuration is
nix/modules/shared/config/emacs/config.org. Home Manager exposes it as
~/.config/emacs/config.org and tangles it during activation with the
Nix-managed Emacs. Generated modules are written below ~/.emacs.d/custom and
~/.emacs.d/lisp; the upstream Purcell bootstrap remains a normal Git
checkout.
Edit the Org source rather than a generated .el file. Apply the result with:
./setup switchThe active package set uses stock Emacs from the pinned Nixpkgs release on
macOS and Linux. On macOS, avoid an unmanaged /Applications/Emacs.app from an
older installation; use the launchd daemon through emacsclient or the managed
application under /Applications/Nix Apps.
The macOS, Ubuntu, and NixOS launchers use a login daemon. While that daemon is idle it preloads common project, version-control, programming, Markdown, and Org libraries. Heavy optional integrations remain deferred until their command or file type is used, and language-server startup never blocks file display.
The dashboard's project entries create or select a Perspective named for that project before invoking Projectile. Recent-file entries detect the file's project root, select the same project-specific Perspective, and add the opened buffer to it. A recent file outside a recognized project stays in the current Perspective.
nix/modules/shared/config/vim/shared.vim is loaded by both Vim and
Neovim. Home Manager installs the compatible plugin set for each editor.
gVim, MacVim, vi, view, and vimdiff inherit the Vim configuration, while
flavor-specific behavior is guarded by feature checks.
Run :VimFlavor inside an editor to see which implementation-specific branch
was selected. The older vimrc and NvChad config.lua remain as reference
material and are not loaded by the active configuration.
Home Manager generates startup files for Bash and Zsh. The shared environment
loads Nix, configures the GPG SSH socket, and installs the same command-line
tools on macOS, NixOS, and generic Linux. Zsh completion deliberately omits
the group-writable Homebrew completion directories that would otherwise cause
compinit security prompts.
tmux uses the screen-256color terminal, mouse support, persistent sessions,
and Vim-aware pane navigation. Its current prefix is C-x. Because C-x is
also a fundamental Emacs prefix, change it in
nix/modules/shared/home-manager.nix only after choosing a replacement
that does not conflict with the desired Emacs, Vim, or shell workflow.
Git uses vim as its editor, signs commits with OpenPGP, pulls with rebase, and
uses a personal include for repositories below ~/Projects. Global ignores
cover macOS metadata, environment files, editor backups, and swap files.
Use this sequence for ordinary changes:
# 1. Edit the Nix module or source configuration.
# 2. Evaluate the selected target.
./setup check
# 3. Build without changing the running system.
./setup build
# 4. Review the working tree.
git diff --check
git diff
# 5. Activate only after the previous steps pass.
./setup switchThe repository may contain personal, uncommitted work. Do not discard or
rewrite unrelated changes while testing a module. Use SYSTEM_CONFIG or
direct flake attribute evaluation when validating an architecture other than
the current machine.
To update pinned inputs deliberately:
nix flake update --flake nix
./setup check
./setup buildReview nix/flake.lock before activating an input update.
Nix keeps generations until garbage collection. If an activation causes a
problem, do not run ./setup clean; first use the platform's generation list
and rebuild/activation rollback facilities.
Git is the source-level rollback mechanism. Commit stable milestones or create
a branch before broad configuration changes. Home Manager's generic-Linux
activation backs up collisions with the .bk suffix.
The pre-merge Emacs repository and live files from 2026-08-12 are preserved in
the local, Git-ignored .rollback directory when that directory is present.
emacs-before-merge-complete-20260812.tar.gz contains the repository snapshot,
and live-emacs-before-activation-20260812.tar.gz contains the live-file
snapshot. These archives are local recovery aids and are not expected in a
fresh clone.
Check which identity SSH selects and whether GitHub accepts it:
./setup gpg
ssh-add -L
ssh -G github.com | grep -E '^(identityagent|identityfile|identitiesonly) '
ssh -T git@github.com
./setup check-keysA public repository can still fail over SSH when the key is not registered with
GitHub. The private secrets input additionally requires repository access.
On Ubuntu, agent refused operation immediately after SSH offers a cardno:
identity usually means Pinentry is attached to an old terminal. The managed
Bash and Zsh startup files set GPG_TTY and update gpg-agent's startup terminal
whenever a new interactive shell opens. For immediate recovery in an existing
shell, run:
export GPG_TTY="$(tty)"
gpg-connect-agent updatestartuptty /bye
ssh -T git@github.comEnter the YubiKey PIN when Pinentry appears and touch the key if it flashes.
If GnuPG instead reports No pinentry, setup installs a temporary
/usr/bin/pinentry-curses setting before evaluation, backing up an older
Home Manager link. The activated profile then replaces it with the permanent
Nix-managed Pinentry path.
Graphical Emacs uses a GTK Pinentry on Linux, with curses and TTY fallbacks for
virtual consoles and headless sessions. If EasyPG reports Inappropriate ioctl for device, run ./setup switch, restart Emacs, and retry; the PIN request
should appear as a desktop dialog instead of trying to open a nonexistent Emacs
daemon TTY.
setup also imports the public OpenPGP certificates published for the saved
GitHub username before asking GnuPG to inspect a connected card. This is
required on a new computer: the YubiKey holds the private ECDH operation, but
GnuPG still needs the matching public certificate in the local keyring. If a
certificate is not published on GitHub, add it to the GitHub account or import
an exported public certificate manually, then run ./setup gpg with the card
connected. No secret key after that normally means the encrypted file targets
a different YubiKey key.
Home Manager is the only GnuPG provider in the managed macOS and Linux
profiles. setup deliberately selects /run/current-system/sw/bin/gpgconf on
macOS/NixOS or ~/.nix-profile/bin/gpgconf on standalone Linux, together with
the matching client and agent programs. On Linux, Home Manager owns the
dirmngr user service and socket as well, so Ubuntu cannot silently reactivate
an older distribution daemon. Activation replaces any already-running stale
instance; Homebrew reconciliation removes undeclared GnuPG/GPGME/Pinentry
formulae.
Ubuntu's /etc/ssh/ssh_config enables GSSAPI, so the Linux profile uses the
GSSAPI-enabled Nixpkgs OpenSSH build. Conventional id_ed25519 and id_rsa
files remain automatic fallbacks when present and are not treated as errors
when absent.
ELPA vterm modules are built with vterm's vendored static libvterm rather than
linking to a Homebrew shared library. During activation, a Linux module that
specifically reports a missing libvterm.so.0 is moved aside with its build
directory. The next M-x vterm rebuilds it automatically; the preserved files
remain beside the package with a broken-system-libvterm-* suffix.
If Nix reports that the revision locked for secrets no longer exists on its
main branch, refresh only that input and retry activation:
./setup refresh-secrets-lock
./setup switchThis updates the private repository revision without changing the pinned Nixpkgs, Home Manager, nix-darwin, or other inputs.
If update-secrets cannot read the remote branch, make sure the GPG-backed SSH
agent exposes an identity registered with GitHub, then retry it from an
interactive terminal so any PIN or security-key prompt can be completed.
The secrets workflow tries gpg-agent first, then automatically tries
id_ed25519 and id_rsa during the migration. A one-time identity can also be
selected explicitly:
SECRETS_UPDATE_IDENTITY="$HOME/.ssh/id_rsa" ./setup refresh-secrets-lockThis override applies only to the lock refresh and does not change the default identity declared by the SSH configuration.
If the recovery command reports that the lock is already current, the apparent
revision error was caused by SSH being unable to refresh Nix's cached checkout.
Register the authentication key reported by the GPG-backed agent with GitHub,
verify it, and then retry switch:
./setup gpg
ssh-add -L
ssh -T git@github.com
./setup switchThis means another GnuPG installation, such as MacGPG/GPGTools, left an older agent attached to the standard socket. The setup and secrets scripts detect this condition and restart the socket with the managed GnuPG version before a signed commit. Cached PINs are cleared, but keys and configuration are kept. Run the GPG action once, then retry the secrets push:
./setup gpg
./setup update-secretsThe first signed commit after an agent restart can require the smart-card PIN
and a hardware touch. Run from a normal interactive terminal and complete
both prompts. If authorization times out, rerun update-secrets; plaintext
was held only in the local source files and temporary encrypted output was
discarded without pushing.
On generic Linux, activation uses the .bk backup extension. If a previous
.bk already exists, inspect both files and move the old backup somewhere safe
before retrying. Do not delete an existing file until its contents have been
compared with the declared replacement.
Check both the current frame and the managed daemon:
emacs --version
emacsclient --eval emacs-versionIf they differ, quit the unmanaged GUI application and open a frame with
emacsclient -c. The managed application is placed below
/Applications/Nix Apps; an older /Applications/Emacs.app may belong to a
manual or previous installation.
Standalone Home Manager still manages only the user environment. The setup
wrapper can install the explicitly declared APT packages and Snaps through
Ubuntu, and it enables snapd.socket when available, but it does not turn
arbitrary Home Manager services into Ubuntu system services. Add required host
packages to nix/modules/linux/apt-packages.nix, configure other daemons
through Ubuntu, or promote that machine to the NixOS configuration when
complete system management is desired.