- Nix 92.6%
- Just 7.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| modules | ||
| .envrc | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| LICENSE | ||
| README.md | ||
nix-config
Personal Nix configuration for macOS (nix-darwin) and NixOS machines.
Structure
modules/
├── machines/
│ ├── nixos/ # Explicit NixOS host definitions
│ │ ├── _common/ # shared NixOS boilerplate
│ │ ├── htpc/
│ │ ├── hass/
│ │ ├── vps/
│ │ └── installer/
│ └── darwin/ # nix-darwin host definitions
│ ├── _common/ # shared darwin boilerplate
│ └── lambda/
├── homelab/ # homelab.services.* option modules (vps + hass services)
├── misc/ # reusable cross-host modules (tailscale, …)
├── users/hdjenkov/ # user account + home-manager entry
├── dots/ # home-manager dotfile modules
└── devshell.nix # flake-parts perSystem: treefmt + dev shell
justfile # deploy/check/update shortcuts
Inputs
Every host tracks the current NixOS release, nixos-26.05. nix-darwin,
Home Manager and nixvim are pinned to the matching branch, so lambda, htpc,
hass and vps share one nixpkgs and one set of module semantics — there is no
separate stable/unstable split to keep in sync.
nixpkgs-unstable is the one exception, consumed only by
modules/dots/vscode. See "App configuration that is not a store symlink".
Machines
| Host | Type | Description |
|---|---|---|
| lambda | nix-darwin (aarch64) | MacBook Pro workstation |
| htpc | NixOS (x86_64) | Home theater PC |
| hass | NixOS (x86_64) | Home automation + print/scan server (LAN only) |
| vps | NixOS (aarch64) | VPS — self-hosted services |
Services
htpc
| Service | Description |
|---|---|
| Kodi | Media center with Jellycon addon |
hass
| Service | Description |
|---|---|
| Home Assistant | Home automation, container (pinned tag, host network) |
| CUPS | LAN print server for the Brother DCP-1510E (brlaser), AirPrint via Avahi |
| scanservjs | Browser scanning UI, driven by the host's SANE/brscan4 over saned |
| Glances | Host metrics over a loopback-only REST API, scraped by Home Assistant |
| cage | Wayland kiosk on the attached monitor: Chromium full-screen on the HA dashboard |
vps
| Service | Description |
|---|---|
| Caddy | Reverse proxy with automatic TLS (Cloudflare DNS) |
| frp | Tunnel server (frps) for home-hosted services |
| Forgejo | Self-hosted git |
| Forgejo Runner | CI runner (docker-in-docker) |
| Headscale | Self-hosted Tailscale control server |
| Headplane | Headscale web UI |
| Pocket ID | OIDC/SSO provider |
| Uptime Kuma | Uptime monitoring |
| FMBot | Last.fm Discord bot (via fmatrix) |
| Epic Games Free Games | Auto-claim free Epic Games |
| Website | Static site |
Home-hosted services tunneled via frp (frpc runs on Unraid):
| Service | Description |
|---|---|
| Immich | Photo library |
| Nextcloud | File storage |
Secrets
Secrets are managed with agenix and stored in a private repository. Each machine decrypts secrets using its SSH host key (NixOS) or personal SSH key (macOS).
Deploy
Use just --list to see all available targets. Common workflows:
just update # preview input updates without changing flake.lock
just update-one nix-darwin # update one input, then validate lambda
just update-apply # update everything, format, evaluate and build lambda
just preflight-lambda # evaluate all systems and build lambda
just diff-lambda # compare the next lambda closure with the live one
just deploy-lambda # preflight, show the nh diff and activate
just generations-lambda # list generations available for rollback
just rollback-lambda # activate the previous generation
just deploy-vps # remote build + switch on vps
just deploy-htpc # remote build + switch on htpc
just deploy-hass # remote build + switch on hass
just build-iso # build installer ISO
Installation
macOS (lambda)
Install Nix via Determinate Systems:
curl -fsSL https://install.determinate.systems/nix | sh -s -- install
Clone the repo:
git clone https://<git-host>/hdjenkov/nix-config.git ~/git/nix-config
Clone the private secrets repo:
git clone git@<git-host>:hdjenkov/nix-private.git ~/git/nix-private
Apply the configuration:
sudo darwin-rebuild switch --flake ~/git/nix-config#lambda --override-input secrets ~/git/nix-private
NixOS (htpc)
Boot from the installer ISO, then from your workstation copy your SSH key to the target:
export NIXOS_HOST=192.168.x.x
ssh-copy-id -i ~/.ssh/personal hdjenkov@$NIXOS_HOST
SSH in with agent forwarding:
ssh -A hdjenkov@$NIXOS_HOST
Enable flakes:
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
Partition and format using disko:
nix --experimental-features "nix-command flakes" run github:nix-community/disko \
-- -m destroy,format,mount \
/path/to/nix-config/modules/machines/nixos/htpc/disk-config.nix
Install git and clone the repo:
nix-env -f '<nixpkgs>' -iA git
mkdir -p /mnt/home/hdjenkov/git
git clone https://<git-host>/hdjenkov/nix-config.git /mnt/home/hdjenkov/git/nix-config
Install:
nixos-install --root /mnt --no-root-passwd \
--flake git+file:///mnt/home/hdjenkov/git/nix-config#htpc
Reboot, then SSH in with agent forwarding and add the host to the secrets repo:
ssh -A hdjenkov@$NIXOS_HOST
cat /etc/ssh/ssh_host_ed25519_key.pub
Add the host key to secrets.nix in the private repo, re-encrypt, and push:
cd ~/git/nix-private
agenix -r
git add -A && git commit -m "add htpc host key" && git push
Pull and deploy:
git clone https://<git-host>/hdjenkov/nix-config.git ~/git/nix-config
git clone git@<git-host>:hdjenkov/nix-private.git ~/git/nix-private # SSH agent forwarded
cd ~/git/nix-config && git pull
sudo --preserve-env=SSH_AUTH_SOCK nixos-rebuild switch --flake ~/git/nix-config#htpc --override-input secrets ~/git/nix-private
NixOS (hass)
Boot the official NixOS minimal ISO and work at the console (user nixos, no
password). On Wi-Fi, connect first with nmtui, or:
sudo systemctl start wpa_supplicant
wpa_cli
> add_network
> set_network 0 ssid "SSID"
> set_network 0 psk "PASSWORD"
> enable_network 0
> quit
Install git and clone the repo:
nix-env -f '<nixpkgs>' -iA git
git clone https://<git-host>/hdjenkov/nix-config.git /tmp/nix-config
Partition and format using disko:
lsblk # confirm the target disk
nix-shell -p disko --run \
"disko -m destroy,format,mount /tmp/nix-config/modules/machines/nixos/hass/disk-config.nix"
disk-config.nixtargets/dev/nvme0n1and wipes it unconditionally.
Use
nix-shellhere, notnix run github:nix-community/disko. The ISO store is a ~2GB tmpfs and the flake pulls its own nixpkgs (330MB unpacked) into it, which runs out of space on 4GB;nix-shelluses the channel already on the ISO.mount -o remount,size=3G /nix/.rw-storebuys headroom if still tight.
Install. No --no-root-passwd here — set a root password, it is the only way
back in if the box boots without network:
sudo nixos-install --root /mnt --flake /tmp/nix-config#hass -j 2 --cores 2
nixos-installbuilds with--store /mnt, so the closure lands on the NVMe, not the ISO's RAM overlay. It also rejects--override-input.
On Wi-Fi, copy the connection profile over before rebooting, or the installed system comes up with no network:
sudo cp /etc/NetworkManager/system-connections/*.nmconnection \
/mnt/etc/NetworkManager/system-connections/
sudo chmod 600 /mnt/etc/NetworkManager/system-connections/*.nmconnection
Reboot, then SSH in and add the host key to the secrets repo:
ssh hdjenkov@hass.local
cat /etc/ssh/ssh_host_ed25519_key.pub
Add the host key to secrets.nix in the private repo, re-encrypt, and push:
cd ~/git/nix-private
agenix -r
git add -A && git commit -m "add hass host key" && git push
Then just deploy-hass from the workstation.
hashedUserPassworddecrypts with the SSH host key, which does not exist until first boot, so console login forhdjenkovonly works after this deploy. SSH key auth and passwordless sudo work immediately.
Manual steps outside Nix, once:
-
Printer —
http://hass:631→ Administration → Add Printer, pick the brlaser PPD. Writes/etc/cups/printers.conf.lpinfo -vgives the device URI if you later wanthardware.printers.ensurePrinters. -
Scanner — check
scanimage -L. If brscan4 misses the device, register it withbrsaneconfig4 -a. The scanservjs UI athttp://hass:8080only sees what the host sees, since it goes through saned rather than USB. -
Home Assistant — onboard at
http://hass:8123. -
Kiosk login — the panel skips the login screen entirely, so no keyboard is ever plugged into it. Add to
/var/lib/home-assistant/configuration.yamland restart the container:homeassistant: auth_providers: # trusted_networks MUST come first. The frontend starts the flow for the # first provider in this list and offers the rest behind "log in with # other providers", so with homeassistant first the panel shows a # password form even though the bypass works. - type: trusted_networks trusted_networks: - 127.0.0.2/32 allow_bypass_login: true # allow_bypass_login only skips the prompt when the address maps to # exactly one user; pinning the id keeps that true if another HA # account is ever added. Ids come from .storage/auth. trusted_users: 127.0.0.2: <user_id> # Listing trusted_networks replaces the default provider list, so # homeassistant must be named explicitly or password login disappears # everywhere — phone included. - type: homeassistantVerify the provider is offered, and only to the kiosk:
curl -s http://127.0.0.2:8123/auth/providers # includes trusted_networks curl -s http://127.0.0.1:8123/auth/providers # homeassistant onlyThe kiosk browses
http://127.0.0.2:8123.kiosk-source-address.serviceadds127.0.0.2/32tolo, which makes it the preferred source for that destination — without the/32the only match is the kernel'slocal 127.0.0.0/8, whose preferred source is127.0.0.1, and the kiosk would be indistinguishable from Caddy. Caddy proxies to127.0.0.1, so LAN clients arriving through the vhost stay untrusted and still get a login screen.Confirm with a real socket, not
ip route get— the latter answers with thelocal 127.0.0.0/8route'sprefsrc(127.0.0.1) and will look like a failure even when everything is correct:bash -c 'exec 3<>/dev/tcp/127.0.0.2/8123; ss -tn "dst 127.0.0.2"'The
Local Addresscolumn is what HA sees. It must read127.0.0.2.Do not swap
127.0.0.2for127.0.0.1here. Caddy stripsX-Forwarded-For(seemodules/homelab/services/home-assistant/default.nix), so HA sees every proxied client as127.0.0.1; trusting it would auto-log-in the whole network. -
Glances integration — Settings → Devices & Services → Add Integration → Glances, once per host:
127.0.0.1(this box), the Unraid box and the OpenWRT router, all on port 61208. Then build the dashboard from the resulting sensors. -
Wi-Fi — the copied profile is mutable state; move the PSK into an agenix secret and
networking.networkmanager.ensureProfilesto make it declarative.
NixOS (vps)
From your workstation, copy your SSH key to the VPS:
export NIXOS_HOST=<vps-ip>
ssh-copy-id -i ~/.ssh/personal root@$NIXOS_HOST
SSH in with agent forwarding:
ssh -A root@$NIXOS_HOST
Enable flakes:
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
Partition and format using disko:
nix --experimental-features "nix-command flakes" run github:nix-community/disko \
-- -m destroy,format,mount \
/path/to/nix-config/modules/machines/nixos/vps/disk-config.nix
Install git and clone the repo:
nix-env -f '<nixpkgs>' -iA git
mkdir -p /mnt/home/hdjenkov/git
git clone https://<git-host>/hdjenkov/nix-config.git /mnt/home/hdjenkov/git/nix-config
Install:
nixos-install --root /mnt --no-root-passwd \
--flake git+file:///mnt/home/hdjenkov/git/nix-config#vps
Reboot, then SSH in and add the host key to the secrets repo:
ssh hdjenkov@$NIXOS_HOST
cat /etc/ssh/ssh_host_ed25519_key.pub
Add the host key to secrets.nix in the private repo, re-encrypt, and push:
cd ~/git/nix-private
agenix -r
git add -A && git commit -m "add vps host key" && git push
Updating
Package ownership
- nix-darwin owns macOS settings, services, fonts, and system integration.
- Home Manager owns user CLI tools and their configuration.
- Homebrew owns native macOS applications and Colima.
- Project-specific language toolchains belong in project dev shells.
App configuration that is not a store symlink
Three GUI apps rewrite their own config files, so Home Manager cannot symlink them read-only out of the store — the app either errors or silently reverts to defaults. They are handled as writable copies instead:
| App | Repo source | Activation behaviour | Import back |
|---|---|---|---|
| Karabiner-Elements | modules/dots/karabiner/karabiner.json |
overwritten on every activation | just karabiner-import |
| VS Code | modules/dots/vscode/settings.json |
seeded only when absent | just vscode-import |
Karabiner is overwritten because its rules only change when you deliberately
edit them — the repo is the source of truth. VS Code's settings.json is only
seeded, because extensions write to it continuously (model pickers, tool
approvals, view state) and an overwrite on every deploy would throw that away.
VS Code extensions are declarative, in modules/dots/vscode/default.nix.
mutableExtensionsDir = true keeps ~/.vscode/extensions writable, so pinned
extensions are symlinked in and anything not packaged in nixpkgs is installed
from the marketplace and survives activation. Currently marketplace-only:
opentofu.vscode-opentofu
Both VS Code and its extensions come from the nixpkgs-unstable input.
Extensions are not backported to the release branch, so the pins there run many
versions behind the marketplace. The two have to move together: an extension
built against VS Code 1.133 can declare an engine range that 26.05's 1.119
refuses to load. This is the only module in the repo that leaves the release
branch.
The Nix copies are read-only, so VS Code cannot update them in place — a version
bump means a just update-one nixpkgs-unstable. After a deploy, run just vscode-dedupe
to drop marketplace copies of extensions Nix now pins; VS Code keeps the higher
version of the two and a stale marketplace copy would silently win.
just vscode-unmanaged lists every version-suffixed (marketplace-installed)
extension, which is the set to review for promotion into the module.
Raycast
Raycast is not declaratively configurable. Extensions, quicklinks, snippets,
aliases and per-command hotkeys live in an opaque store under
~/Library/Application Support/com.raycast.macos. Only the global hotkey and
the hyper-key icon are defaults keys, and those are set in
modules/machines/darwin/lambda/system.nix.
Everything else comes back through Raycast Settings → Cloud Sync, which must be enabled before wiping. The extensions currently installed, for a manual rebuild if sync is unavailable:
Spotify Player, Color Picker, GitLab, GIF Search, Coffee, Brew, Visual Studio Code, Tailscale, Kill Process
Darwin activation installs missing Homebrew declarations and uninstalls
undeclared packages. It uses Homebrew's normal uninstall mode, not zap, so
application preferences and support data are preserved. Activation does not
update or upgrade installed packages; run just brew-maintenance explicitly
when you want to do that. Run just brew-cleanup-preview to see what activation
would remove before deploying.
Sikarugir is declared with per-cask trust because it comes from a third-party tap. The rest of that tap is not trusted.
System updates
lambda:
cd ~/git/nix-config && git pull
sudo darwin-rebuild switch --flake ~/git/nix-config#lambda --override-input secrets ~/git/nix-private
Or with just:
just preflight-lambda
just diff-lambda
just deploy-lambda
If the new generation causes a regression, just rollback-lambda activates the
previous generation. just generations-lambda shows the retained history.
htpc (SSH in with -A, then):
cd ~/git/nix-config && git pull
sudo nixos-rebuild switch --flake ~/git/nix-config#htpc
After the first successful deploy, htpc preserves
SSH_AUTH_SOCKthrough sudo automatically, so it can fetch the secrets repo directly without--override-input.
vps (built and deployed remotely from workstation):
cd ~/git/nix-config && git pull
just deploy-vps
Or manually:
nixos-rebuild switch --flake .#vps \
--target-host hdjenkov@<vps-host> \
--build-host hdjenkov@<vps-host> \
--sudo --ask-sudo-password