Personal NixOS configuration using flakes, featuring an ephemeral-root setup (via preservation) with encrypted BTRFS filesystem.
- Impermanence: Root is a tmpfs (RAM-backed), so the filesystem is empty on every boot for enhanced security; only explicitly preserved paths survive
- Encryption: Full disk encryption with LUKS, supporting Yubikey FIDO2 authentication
- Secure Boot: Implemented via lanzaboote
- Declarative: Everything managed through Nix flakes
- Multi-host: Support for desktops, laptops, and servers
This flake exports portable Home Manager modules under homeModules. Import
them into a Home Manager user configuration, including one managed by nix-darwin.
The files under modules/ are flake-parts aspects, not directly importable Home
Manager modules; use the flake exports instead.
| Export | Includes |
|---|---|
helix |
Editor settings, terminal-color theme, default EDITOR/VISUAL |
helixLanguages |
Imports helix; Markdown, Nix, Go, YAML, Python and HCL tooling |
zellij |
Keybindings, fallback theme and the Rust layout |
nushell |
Shell settings, formats plugin, Carapace and conditional app integrations |
zsh |
Completion, autosuggestions, syntax highlighting, Carapace and shared aliases |
starship |
Prompt with a standalone terminal palette |
bat, eza, zoxide |
Individual CLI app configurations |
cliTools |
Those three apps plus fd, procs, sd, dust, ripgrep, bottom, htop, wget, jq and dig |
git |
Git preferences and ignores, without identity or signing policy |
difftastic |
Imports git; enables its diff integration |
direnv |
direnv and nix-direnv, including Home Manager's shell integrations |
gh |
GitHub CLI aliases and gh-dash, gh-eco and gh-markdown-preview |
gpg |
Imports git; personal public key, YubiKey settings, native pinentry and commit signing |
kubernetes |
kubectl, kubelogin, Flux, kind, kubectx/kubens, minikube, k9s and shell completion |
bash |
Bash configuration and ~/.local/bin on the session path |
terminalSuite |
Helix, Zellij, Zsh, Starship, cliTools, Git, difftastic, direnv, gh, GPG, Kubernetes clients and Bash |
terminalSuite does not include the heavier helixLanguages tooling. Import it
separately when needed. Imports are the toggle; there is no additional enable
namespace. Ordinary Home Manager options remain available for customization:
# Inside home-manager.users.<your-user>, or a standalone home configuration:
{
imports = [
inputs.dotfiles.homeModules.terminalSuite
# inputs.dotfiles.homeModules.helixLanguages
# inputs.dotfiles.homeModules.gpg
];
home.stateVersion = "26.05"; # Preserve your existing value when adding modules.
programs.git.settings.user = {
name = "Your Name";
email = "you@example.com";
};
programs.helix.settings.theme = "base16_default_dark";
programs.zellij.settings.default_shell = "/bin/zsh";
programs.gh.settings.editor = "hx";
}See the complete nix-darwin example for inputs and
Home Manager wiring. Copy it into your own configuration, replace alice, select
your Mac's architecture, and retain existing system/Home Manager state versions.
The suite configures Zsh but does not change the account's login shell. This fits
macOS, where Zsh is already the default. Launch Zsh or Zellij from your terminal
application. Use a Nerd Font for the prompt glyphs; terminal application settings
and font installation belong to the consuming machine.
Modules use the consumer's pkgs. They are checked with Home Manager and nixpkgs
26.05. The optional dotfiles.overlays.terminal selects this repo's unstable
Helix, Nushell/plugins and Carapace together; apply it to the nixpkgs instance
used by Home Manager. Avoid changing just Nushell while retaining plugins from a
different revision. On Intel Macs the terminal overlay is a no-op because
nixpkgs unstable has dropped x86_64-darwin; use the supported 26.05 package set.
The full overlays.default includes personal Linux desktop
packages and is not needed here.
On macOS, Nushell does not create or load private.nu or any other private file.
Add personal shell configuration directly through programs.nushell.extraConfig
and programs.nushell.extraEnv in your private nix-darwin repo. Home Manager uses
the macOS Library/Application Support/nushell config directory by default;
with xdg.enable = true, it uses xdg.configHome instead. Linux retains the
existing mutable private.nu under programs.nushell.configDir, creating it if
missing and sourcing it on startup. Integrations with Helix, bat, eza, Git and
Zellij are added only when those apps are enabled.
Zellij prefers Zsh, then Nushell, and otherwise falls back to Nix's Bash. Its Rust
layout requires Helix, direnv, entr and a project environment providing Cargo;
the module supplies the first three, not the project toolchain.
Git identity, preservation, Foot, Noctalia and Linux-only agent services remain in
the NixOS composition. Exported modules do not create accounts or set state
versions. GitHub CLI authentication remains a separate gh auth login step.
checks.<system>."home:<name>" evaluates/builds each module, the suite, a suite
with the terminal overlay, and an override/custom-path configuration. Checks are
provided for x86_64-linux, aarch64-darwin and x86_64-darwin; this does not make
the existing Linux development shells portable.
# Evaluate all platforms without building or activating a home:
nix flake check --all-systems --no-build --accept-flake-config
# Build the suite on an Apple Silicon Mac (does not activate it):
nix build '.#checks.aarch64-darwin."home:terminalSuite"' --accept-flake-configNew files must be tracked by Git before a normal flake reference includes them.
During development, path:. also includes untracked files. The repository's flake
evaluation uses Nix's pipe-operators experimental feature; consumers may need
--extra-experimental-features pipe-operators when evaluating the input.
# Clone the repository
git clone https://github.com/TGuimbert/dotfiles.git
cd dotfiles
# Enter the NixOS development shell (Linux or Apple Silicon macOS)
nix develop .#nixosThis process is for physical machines where you have direct access (leshen, griffin).
# Clone this repository
git clone https://github.com/TGuimbert/dotfiles.git
cd dotfiles
# Set your target hostname
export NEW_HOSTNAME=<hostname> # e.g., griffin, leshensudo nix --experimental-features "nix-command flakes" run github:nix-community/disko -- \
--mode disko ./modules/_hosts/$NEW_HOSTNAME/disks.nixsudo -s
mkpasswd -s > /mnt/persistent/tguimbert-password
exitlanzaboote cannot sign anything until sbctl create-keys has produced a PKI
bundle, which only exists once the machine is up. So install without it: drop
secureBoot from the host's import list in modules/machines/<hostname>.nix,
and add it back in step 6.
Imports are the toggle here — don't edit modules/secure-boot.nix to disable it.
sudo nixos-install --no-root-password --flake ./#$NEW_HOSTNAMEReboot when complete.
After the first boot, set up Secure Boot:
# Verify boot status
bootctl status
# Enable secure boot in your configuration and rebuild
nh os switch
# Create secure boot keys
sudo sbctl create-keys
# Sign the keys by rebuilding
nh os switch
# Verify everything is signed (only bzImage.efi should be unsigned)
sudo sbctl verify
# Reboot and enable Secure Boot in BIOS
# Then enroll the keys
sudo sbctl enroll-keys --microsoft
# Reboot and verify
bootctl statusDon't forget to set a BIOS password!
For headless servers (e.g., srv-01), use nixos-anywhere for remote installation.
# Install directly to a remote host
nix run github:nix-community/nixos-anywhere -- \
--flake .#srv-01 \
--target-host root@10.0.0.108Replace 10.0.0.108 with your server's IP address.
The repository includes a bootstrap script that automatically handles SSH key deployment:
# Edit the script with your server IP
nano scripts/bootstrap-srv-01.nu
# Run the bootstrap (requires Bitwarden CLI and nushell)
./scripts/bootstrap-srv-01.nuThe bootstrap script will:
- Retrieve SSH host keys from Bitwarden
- Deploy them during installation
- Install NixOS using nixos-anywhere
Enhance security by using your Yubikey to unlock the encrypted partition:
sudo cryptsetup luksHeaderBackup /dev/nvme0n1p2 \
--header-backup-file /run/media/tguimbert/<usb-key-name>/luks_backup.binsudo systemd-cryptenroll /dev/nvme0n1p2 --fido2-device=autosudo systemd-cryptenroll /dev/nvme0n1p2 --recovery-keyImportant: Write down the recovery key and store it safely!
Note: The boot keyboard is in QWERTY layout.
sudo systemd-cryptenroll /dev/nvme0n1p2 --passwordsudo systemd-cryptenroll /dev/nvme0n1p2 --wipe-slot=0Reboot and verify that all enrollment methods work before relying on them.
The root filesystem (/) is a tmpfs (RAM-backed); persistent state lives on BTRFS subvolumes:
| Mount point | Backing | Persistence | Purpose |
|---|---|---|---|
/ |
tmpfs (RAM) | Ephemeral | System root, empty on every boot |
/tmp |
preservation bind-mount | Cleaned on boot | Disk-backed temp (keeps builds off RAM) |
/nix |
btrfs | Persistent | Nix store |
/persistent |
btrfs | Persistent | Stateful data |
/var/log |
btrfs | Persistent | System logs for debugging |
/.swapvol |
btrfs | Persistent | Swap file (desktop hosts) |
The system implements an ephemeral-root layout (via the preservation module) where the root is reset on each boot:
- Root (
/) is a tmpfs, so it is empty on every boot — no wipe/rollback service is needed - Only explicitly configured paths in
/persistentsurvive reboots /tmpis disk-backed (a preservation bind-mount) and cleaned each boot viaboot.tmp.cleanOnBoot
Benefits:
- No accumulation of cruft over time
- Better security (temporary files are truly temporary)
- Reproducible system state
- Forces explicit declaration of important data
The repository uses CI to automatically update flake inputs. Renovate opens a weekly
lockfile PR, CI builds every host and pushes the closures to tguimbert.cachix.org, and
minor updates automerge once the checks are green.
Desktops (leshen, griffin) — pull and switch by hand:
cd ~/.dotfiles
git pull # flake.lock is updated by CI
nh os switchsrv-01 — updates itself. system.autoUpgrade (modules/auto-upgrade.nix) pulls
github:TGuimbert/dotfiles nightly at ~03:00, substituting from cachix what CI already
built, and reboots only if the kernel changed and only between 03:00 and 05:00. There is
no alerting on failure, so if the host looks stale:
just upgrade-now srv-01 # run the timer's job now
just upgrade-log srv-01 # the last two nights' runsNote: Flake updates are managed by CI (Renovate), so you typically don't need to run nix flake update manually.
Your own changes are pushed from a desktop rather than waiting for the nightly pull — the closure is built locally and copied over SSH:
just deploy srv-01 # build here, activate there
just deploy-boot srv-01 # only make it the boot default
just deploy srv-01 10.0.0.57 # same, with an explicit ssh targetThe argument is the nixosConfigurations name; the ssh target defaults to <host>.local.
That is mDNS on purpose — srv-01 has a static address and never takes a DHCP lease, so the
router's lan zone never learns its name (srv-01.lan does not resolve, srv-01.local
does). home.guimbert.fr remains the namespace for services; mDNS names the machine.
Override the target as above if multicast is ever in the way (VLANs, AP isolation).
just --list shows the rest (just check, just build <host>, …). just comes from the
nixos dev shell, which direnv loads in this directory. Because flake refs only see
git-tracked files, git add any brand new module before deploying.
srv-01 never pushes a backup. A timer stages its service state into /var/backup/data at
01:00 — every sqlite database goes through the online-backup API, so they are consistent rather
than caught mid-write — and the TrueNAS pulls that over SFTP an hour later. History is the NAS's
snapshot schedule, so pick a snapshot there and copy its contents to the host first.
Then, on srv-01:
systemctl stop lldap authelia-main
rsync -a <pulled>/lldap/ /var/lib/lldap/
chown -R lldap:lldap /var/lib/lldap && chmod 0750 /var/lib/lldap
chmod 0400 /var/lib/lldap/server_key
rsync -a <pulled>/authelia-main/ /var/lib/authelia-main/
chown -R authelia-main:authelia-main /var/lib/authelia-main
chmod 0700 /var/lib/authelia-main
systemctl start lldap authelia-mainThe media services restore the same way — indexers, quality profiles, libraries and watch history, none of which is reproducible from a rebuild:
systemctl stop jellyfin sonarr radarr prowlarr bazarr seerr
for svc in jellyfin sonarr radarr prowlarr bazarr jellyseerr; do
rsync -a <pulled>/$svc/ /var/lib/$svc/
chown -R $svc:$svc /var/lib/$svc
done
systemctl start jellyfin sonarr radarr prowlarr bazarr seerrThe unit is seerr.service while its state lives in /var/lib/jellyseerr — nixpkgs renamed the
module, the directory predates the rename, and the loop above reflects both.
The books half restores differently, because Grimmory's state is not a directory. Its library
metadata, users and OIDC client are in MariaDB, and /var/lib/grimmory holds only covers and
reader state:
systemctl stop podman-grimmory shelfmark
runuser -u mysql -- mariadb < <pulled>/mysql/grimmory.sql
rsync -a <pulled>/grimmory/ /var/lib/grimmory/
chown -R grimmory:media /var/lib/grimmory && chmod 0750 /var/lib/grimmory
rsync -a <pulled>/shelfmark/ /var/lib/shelfmark/
chown -R shelfmark:shelfmark /var/lib/shelfmark && chmod 0700 /var/lib/shelfmark
systemctl start podman-grimmory shelfmarkThe dump carries its own CREATE DATABASE, so it can be replayed into an empty MariaDB — which
is what a rebuilt host has, since services.mysql creates the database but nothing in it. Grimmory
migrates the schema forward on start, so a dump from an older tag restores into a newer one.
runuser is needed for the same reason it is in the backup job: MariaDB's superuser is the mysql
OS user, authenticated by which account opened the socket.
Paperless restores differently again, because it is the one service backed up by its own tooling.
What the NAS holds is a document_exporter tree — the originals, the archive PDFs and a manifest
carrying every tag, correspondent and date — rather than a copy of /var/lib/paperless and a
PostgreSQL dump:
systemctl stop paperless-{scheduler,web,consumer,task-queue}
rsync -a <pulled>/paperless/ /var/lib/paperless/export/
chown -R paperless:paperless /var/lib/paperless/export
systemctl start paperless-scheduler
paperless-manage document_importer /var/lib/paperless/exportdocument_importer refuses to run against an instance that already holds documents, so a rebuilt
host — where the migrations have run and nothing has been ingested — is exactly the right target.
The superuser comes back from sops on first start either way; the Authelia identity has to be
linked again from the user menu → My Profile → Connect new social account, since that link lives
in the database this replaces.
Mealie is an ordinary directory again, and the one place its restore differs is what is in the directory: alongside the recipes and their images sits the app secret it signs tokens with, so a restore keeps existing sessions valid rather than logging everyone out.
systemctl stop mealie
rsync -a <pulled>/mealie/ /var/lib/mealie/
chown -R mealie:mealie /var/lib/mealie && chmod 0700 /var/lib/mealie
systemctl start mealieRadicale is the simplest restore here, and the only one with no database at all — the collections
are plain .ics and .vcf files, and the accounts that own them live in LLDAP, restored above:
systemctl stop radicale
rsync -a <pulled>/radicale/ /var/lib/radicale/
chown -R radicale:radicale /var/lib/radicale && chmod 0750 /var/lib/radicale
systemctl start radicale.Radicale.cache comes back with it and is harmless; Radicale rebuilds it if it is stale or
missing. The collection paths are LLDAP uids, so they line up as long as the same accounts exist.
CouchDB is an ordinary directory too, and needs no dump either — its file format is append-only, so the nightly copy is valid even though it was taken while CouchDB was serving:
systemctl stop couchdb
rsync -a <pulled>/couchdb/ /var/lib/couchdb/
chown -R couchdb:couchdb /var/lib/couchdb && chmod 0750 /var/lib/couchdb
systemctl start couchdbWhat comes back is the vaults, the _users accounts the Obsidian clients authenticate with and
each database's _security doc — none of which this repo can reproduce. local.ini and
.erlang.cookie come back with it: the cookie is harmless on a single node, but local.ini
carries the hashed admin password as it was, and outranks the sops one. If couchdbAdminPassword
has changed since the backup, rm /var/lib/couchdb/local.ini before starting and CouchDB will
re-hash from sops.
Miniflux is the mirror image of Radicale: nothing but a database. Feeds, entries, users, API keys and the OIDC link are all rows in the PostgreSQL cluster it shares with Paperless, and there is no directory to rsync:
systemctl stop miniflux
runuser -u postgres -- psql miniflux < <pulled>/postgresql/miniflux.sql
systemctl start minifluxThe dump carries no CREATE DATABASE — the target is the empty database services.miniflux
declares on a rebuilt host — and runuser is needed for MariaDB's reason above: peer
authentication on the socket authenticates the OS user, and PostgreSQL's superuser is postgres.
The local admin comes back from sops on first start either way, and the Authelia link is in the
dump. Paperless shares that cluster but is not restored this way: its own exporter above is what
carries it, which is why the dump names one database rather than being a pg_dumpall.
Jellyfin's artwork is deliberately not in the backup — it re-fetches it — so expect the libraries to look bare until the first metadata scan finishes. Neither SABnzbd nor Recyclarr is in the backup either: the first holds a re-downloadable queue, the second a clone of the TRaSH guides and a config generated from the repo, so both rebuild themselves. The media itself was never at risk: it lives on the NAS, and srv-01 only mounts it — books included, since they moved into the same export.
The staged copy is read by a single unprivileged account, so ownership is flattened on the way out — hence the chown steps. Traefik is not in the backup and needs nothing: it re-issues the wildcard certificate from Cloudflare DNS on first start.
Registered passkeys, TOTP enrolments and OIDC consent grants all live in the Authelia database
restored above, so they survive with it. Authelia's OIDC issuer key does not — it comes from
sops (autheliaOidcIssuerPrivateKey), so a host rebuilt from this repo keeps the same key and
existing OIDC clients need no reconfiguration.
Gatus and the Beszel agent are declared in this repo, but the Beszel hub lives on the TrueNAS — deliberately, so that whatever reports srv-01 being down is not itself on srv-01. That makes the first run a bootstrap, since the agent cannot be configured before the hub exists to mint its token:
- Pushover — create an application; note its API token and your user key.
- TrueNAS — Apps → Discover → install Beszel Hub from the Community train, and front
it with the NAS's Traefik at
beszel.home.guimbert.fr. Create the first user. Two requirements on that route, because the agent connects through it:beszelmust resolve to the NAS, not to srv-01.- It must not sit behind the authelia middleware. A forward-auth redirect on the agent's
upgrade request to
/api/beszel/agent-connectstops it connecting; put the whole route in the clear and rely on Beszel's own login. That also means you can still open the dashboard when srv-01 — and with it Authelia — is the thing that is down.
- In the hub, add a system for srv-01 and copy the
KEYandTOKENit shows. Add a notification URLpushover://shoutrrr:<api-token>@<user-key>/and a Status alert on that system — this is the alert that fires when srv-01 stops reporting. - TrueNAS — System → Alert Settings, and point its Email alert service at your inbox. The split is: Gatus watches the NAS's web UI, because a NAS that is down cannot email you that it is down; everything it can report while running — pool degradation, SMART, scrub errors — goes by email and is the one signal that does not arrive on Pushover. Gatus does not poll those: the only HTTP route is the REST API, deprecated in 25.04, removed in 26, and alerted on daily by TrueNAS for merely being used.
sops secrets/srv-01.yamland add:gatusEnvironments—PUSHOVER_TOKEN,PUSHOVER_USER_KEY, andGATUS_HEARTBEAT_TOKEN(openssl rand -hex 32).beszelAgentEnvironment—TOKENandKEYfrom step 3.
just deploy srv-01, then checkjournalctl -u beszel-agent | grep -i 'websocket connected'and openhttps://gatus.home.guimbert.fr.
To add a check later, edit modules/server/gatus.nix and deploy — there is no UI to click,
which is the trade that keeps the monitor list in git and out of the backups.
Mealie authenticates against Authelia over OIDC, and gates on LLDAP group membership. Neither the groups nor the first admin can come from this repo, so the first run is a short bootstrap — and step 4 is not optional:
- LLDAP (
https://ldap.home.guimbert.fr) — create the groupsmealie-usersandmealie-admins, and add yourself to both. Check the account has an email set: Mealie refuses any OIDC login whoseemail_verifiedclaim is absent, and Authelia sources both from LLDAP. sops secrets/srv-01.yamland add a pair generated withauthelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72:autheliaOidcMealieClientSecret— the plaintext, whichmodules/server/mealie.nixrenders into Mealie's environment file.autheliaOidcMealieClientSecretDigest— the digest, whichmodules/server/authelia.nixreads.
just deploy srv-01, openhttps://mealie.home.guimbert.frand use Login with Authelia. The account is created on the spot, with admin rights taken from the group claim.- Settings → Users → delete
changeme@example.com. Mealie seeds that account with the passwordMyPasswordand offers no way to configure it away, and this route carries no Authelia middleware. It is reachable from the LAN only, but the window should be minutes.
Radicale authenticates its own clients against LLDAP, because CalDAV and CardDAV clients send HTTP Basic and cannot complete a browser SSO round trip. Neither the group it gates on nor the account it binds with can come from this repo, so the first run is a short bootstrap:
- LLDAP (
https://ldap.home.guimbert.fr) — create the groupradicale-usersand add yourself. Then create a userradicalewith a generated password and add it tolldap_strict_readonly: LLDAP refuses anonymous search, and Radicale always searches for a user's DN before binding as them, so the read-only account is what makes any login work at all. sops secrets/srv-01.yamland addradicaleLdapPassword— that account's password, whichmodules/server/radicale.nixhands to Radicale asldap_secret_file.just deploy srv-01, then openhttps://radicale.home.guimbert.frand log in with your LLDAP credentials (not an Authelia session — this route carries no middleware). Create a calendar and an address book from the web UI; Radicale creates no collections on its own.- Point clients at
https://radicale.home.guimbert.fr— DAVx5 and Thunderbird discover the collections through/.well-known/{caldav,carddav}, which Radicale redirects to/.
Access is the group, so revoking someone is removing them from radicale-users; it takes
effect within the 15 seconds cache_logins holds a successful login for.
CouchDB exists for one thing: the remote database the Obsidian Self-hosted LiveSync plugin
replicates a vault through. It has no LDAP of any kind, so unlike Radicale it cannot delegate to
LLDAP — the credential is local to CouchDB. modules/server/couchdb.nix declares the server
config and a server admin from sops; the account the plugin carries is a non-admin one
created here, so a credential sitting in plaintext on a phone cannot rewrite the server's
configuration or delete the database.
couchdbAdminPassword is already in secrets/srv-01.yaml; read it back with
sops -d secrets/srv-01.yaml. Then, after just deploy srv-01:
- Create the vault's database.
PUT /<db>is server-admin only, which is exactly why the plugin's account cannot do it:curl -su couchdb-admin:<admin-pw> -X PUT https://couchdb.home.guimbert.fr/<vault>
- Create the account the clients will use. The password is sent in the clear and CouchDB hashes
it on save:
curl -su couchdb-admin:<admin-pw> -X PUT \ https://couchdb.home.guimbert.fr/_users/org.couchdb.user:<name> \ -H 'Content-Type: application/json' \ -d '{"name":"<name>","password":"<pw>","roles":["obsidian-livesync"],"type":"user"}'
- Grant it by role, not by name, so a second vault later needs no edit to this document:
curl -su couchdb-admin:<admin-pw> -X PUT \ https://couchdb.home.guimbert.fr/<vault>/_security \ -H 'Content-Type: application/json' \ -d '{"admins":{"names":[],"roles":[]},"members":{"names":[],"roles":["obsidian-livesync"]}}'
memberswith both lists empty would make the database readable by every authenticated user; the role is what prevents that. - In Obsidian, install Self-hosted LiveSync, pick manual setup, and enter
https://couchdb.home.guimbert.fr, the account from step 2 and the database from step 1. Thehttps://is load-bearing: Traefik 308-redirectshttp://, and a CORS preflight may not follow a redirect, so anhttp://URI fails with "the request was successful by API. But the native fetch API failed! Please check CORS settings" — which sends you hunting through[cors]for a problem that is not there. Skip "Check and fix CouchDB issues" — see below. Generate a Setup URI on that device and import it on the others.
One account per vault, not per device. Every device on a vault must point at the same
database, and a database's grant is its _security doc, so per-device accounts would all be
members of the same database with identical rights — no isolation, three Setup URIs to mint. A
second vault is what justifies a second account: it is a second database, and step 2's role
already covers it.
Two things the non-admin account cannot do, both by design:
- "Check and fix CouchDB issues" (in the setup wizard and the settings pane) reads
/_node/_local/_config, which is admin-only, and returns 403. Nothing here needs it: those settings are declared inmodules/server/couchdb.nix, and the "fix" button would write them to/var/lib/couchdb/local.ini, which outranks the generated config from then on. - "Rebuild everything" / reset-remote deletes and recreates the database, also admin-only. Paste the admin credential into the plugin for that one operation, then put the account back.
Routine compaction needs neither: CouchDB's smoosh daemon compacts on its own by default.
Miniflux is the feed reader. It authenticates against Authelia over OIDC, but — unlike Mealie — the identity is linked onto an existing account rather than creating one, so the first run needs the local admin the module seeds from sops:
sops secrets/srv-01.yamland add three values:minifluxAdminPassword— the local admin's password, at least six characters. The account istguimbert, named inmodules/server/miniflux.nix.autheliaOidcMinifluxClientSecretandautheliaOidcMinifluxClientSecretDigest— a pair fromauthelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72, exactly as for Mealie: the plaintext goes into Miniflux's environment file, the digest intomodules/server/authelia.nix.
just deploy srv-01, thenssh srv-01.local journalctl -u miniflux | grep -i oidc. A wrongOAUTH2_OIDC_DISCOVERY_ENDPOINTfails here, at startup (Failed to initialize OIDC provider) and not at the first login.- Open
https://miniflux.home.guimbert.fr, log in withtguimbertand the password from step 1, and use Settings → Link my Authelia account (the link at the top of the page). Log out and back in through Sign in with Authelia to confirm it took. Account creation over OIDC is off, so that link is the only other way in besides the password — which stays enabled on purpose: it is what still works when Authelia is the thing that is down. - For phone and desktop reader apps, Settings → Integrations: Miniflux serves its own API and also the Fever and Google Reader ones, each with a credential generated there. The route carries no Authelia middleware precisely so those clients work.
Adding feeds needs no bridge: Reddit publishes https://www.reddit.com/r/<sub>/.rss, and the same
for a user or a multireddit, as do YouTube channels. A 403 from Reddit is it refusing the
fetcher's user agent, not a misconfiguration — set a per-feed User Agent in the feed's
settings.
Readeck is the bookmark and read-later library — the other end of Miniflux. It authenticates over OIDC against Authelia, and access is gated on an LLDAP group, because Readeck's own group mapping only chooses a role and cannot refuse anyone. Neither the groups nor the first account can come from this repo, so the first run is a short bootstrap:
- LLDAP (
https://ldap.home.guimbert.fr) — create two groups and add yourself to both:readeck-users— the gate.modules/server/authelia.nixrefuses anyone outside it, through a customauthorization_policies.readeckentry rather than anaccess_controlrule; an OIDC route carries no forward-auth middleware, so those rules never apply to it.readeck-admins— the role.modules/server/readeck.nixmaps it onto Readeck'sadmingroup. Not optional if you want to be an admin: the map is re-applied on every login, so an account outside this group is set back tousereach time it signs in.
sops secrets/srv-01.yamland add three values:readeckSecretKey— 48 random bytes, base64:openssl rand -base64 48. Readeck derives its session, token and TOTP keys from it. Do not skip it and do not rotate it casually: left unset, Readeck tries to write a generated key into its config file, which is a store path, and the service fails to start; changing it later invalidates every session and API token.autheliaOidcReadeckClientSecretandautheliaOidcReadeckClientSecretDigest— a pair fromauthelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72, exactly as for Mealie and Miniflux: the plaintext goes into Readeck's environment file, the digest intomodules/server/authelia.nix.
just deploy srv-01, then openhttps://readeck.home.guimbert.fr. With no users yet it redirects to/onboarding, which creates the first account with a local password. Use the same username and email as your Authelia identity, and keep that password in your password manager.- Sign out, then sign back in with Sign in with Authelia. Readeck matches on username or
email and links the OIDC identity onto the account from step 3 rather than creating a
second one — so you keep the local password, which is the way in when Authelia is the thing
that is down. Confirm the role stuck: an admin sees the Administration section.
A wrong issuer URL fails here, at the first login, not at startup — unlike Miniflux — so
check
ssh srv-01.local journalctl -u readeckat this step rather than after the deploy. - Wire up Miniflux: Settings → Integrations → Readeck, with
https://readeck.home.guimbert.frand an API token minted in Readeck under Profile → API tokens. Save entry on an article then files it into the library. The same token works for the browser extension, and OPDS readers point at/opds— none of which would work behind the Authelia middleware, which is why this route carries none.
Revoking someone is removing them from readeck-users; their existing bookmarks stay, and the
account is refused at the Authelia consent step on the next login.
The UPS is on the TrueNAS's USB port and the NAS runs NUT's upsd as the primary;
modules/server/ups.nix makes srv-01 a secondary that shuts itself down ten minutes into an
outage, handing the rest of the battery to the NAS. The account it authenticates with lives in the
NAS's upsd.users and cannot come from this repo, so the first run is a short bootstrap:
-
TrueNAS — System Settings → Services → UPS. Confirm the Identifier (
upsis the default, and whatmodules/server/ups.nixexpects) and that the local side works:upsc upson the NAS should print the UPS's variables. -
Tick Remote Monitor. That toggle and nothing else is what turns
LISTEN 127.0.0.1intoLISTEN 0.0.0.0 3493— the docs describe it as also setting a well-known user and password, which is stale: those are just the default values of the Monitor User/Monitor Password fields. Leave those as the NAS's own; srv-01 does not reuse them. -
Extra Users — this field is pasted into
upsd.usersverbatim, so put a dedicated account for srv-01 in it:[srv-01] password = <a generated alphanumeric password> upsmon secondaryNo leading tab on the
[srv-01]line, and leave a trailing newline: NAS-121082 is an open bug where stray whitespace in this field makes the account silently fail to authenticate.secondaryand notmaster— this host runs nothing that another waits on. -
Shutdown Mode must stay "UPS reaches low battery". On "UPS goes on battery" the NAS declares FSD the moment the power fails, which takes srv-01 down with it and makes the ten-minute timer dead config. Restart the UPS service.
-
sops secrets/srv-01.yamland addupsMonitorPassword— the same password, whichmodules/server/ups.nixhands to upsmon throughLoadCredential. Avoid a literal": the generatedMONITORline quotes it. -
just deploy srv-01, then check from srv-01:upsc ups@10.0.0.55 ups.status # OL journalctl -u upsmon -n 50 # no "Login failed", no "Primary privileges unavailable" systemctl status ups-early-shutdown.path
To exercise the timer without pulling the mains lead, inject the notification upsmon would send —
upssched is a daemon holding a timer, so this is the only way to test it end to end:
sudo -u nutmon env NUT_CONFPATH=/etc/nut UPSNAME=ups@10.0.0.55 NOTIFYTYPE=ONBATT upssched
sudo -u nutmon env NUT_CONFPATH=/etc/nut UPSNAME=ups@10.0.0.55 NOTIFYTYPE=ONLINE upsschedThe first starts the real 600-second timer; run the second, or the host powers off in ten
minutes. To confirm the privileged half on its own, sudo -u nutmon touch /run/upssched/early-shutdown — srv-01 should power off immediately.
Two things to expect from a real outage. srv-01 does not come back by itself: it powers off
while the UPS still has charge, so its outlet is never de-energized, and it needs a manual
power-on unless the BIOS is set to restore on AC power loss and the outage outlasts the battery.
And if the NAS is what dies rather than the mains, srv-01 has no other source of UPS state — it
logs COMMBAD/NOCOMM and keeps running.
Secrets are managed with SOPS (uses age encryption):
# Edit secrets (auto-decrypts/encrypts)
sops secrets/common.yaml
sops secrets/srv-01.yaml# Enter a development environment
nix develop .#<shell-name>
# Available shells:
# - nixos, python, rust, go, ops, markdown, nodejs, protobuf
# - python-nodejs, python-protobuf (combined shells)# Format the tree (nixfmt via treefmt; `just fmt` is the same thing)
nix fmt
# Lint Nix files
statix check
# Format-check + lint + evaluate every host and shell
just check.
├── flake.nix # Inputs only; outputs = import ./outputs.nix
├── outputs.nix # flake-parts mkFlake + import-tree ./modules
├── modules/ # All modules auto-imported (dendritic pattern)
│ ├── nixos.nix # Scaffolding: merge points + central generation
│ ├── home-manager.nix # Scaffolding: homeManager.modules merge points
│ ├── nixpkgs.nix # Scaffolding: nixpkgs config + overlays
│ ├── eval-modules.nix # Scaffolding: evalModulesModule helper
│ ├── users.nix # Scaffolding: user + home-manager wiring
│ ├── boot.nix … # Flat feature files (one capability each)
│ ├── machines/ # Per-host thin import lists (leshen, griffin, …)
│ ├── _hosts/ # Per-host hardware.nix + disks.nix (skipped by import-tree)
│ ├── desktop/ # Desktop capability (niri, noctalia, greeter, appearance, firefox)
│ ├── server/ # Server services (traefik, authelia, lldap, …)
│ └── shells/ # Development shell environments
├── config/ # Static config files (nushell, zellij, k9s)
└── secrets/ # SOPS encrypted secrets
If the system fails to boot after changes:
- Select an older generation from the boot menu
- Roll back:
sudo nixos-rebuild switch --rollback
If you need to persist a new directory or file:
- System-level: Add to
environment.persistence."/persistent"in the host config - User-level: Add to
home.persistence."/persistent"in home configuration
If you lose access:
- Boot from NixOS installer
- Decrypt LUKS:
cryptsetup open /dev/nvme0n1p2 encrypted - Mount BTRFS:
mount /dev/mapper/encrypted /mnt - Access your data in
/mnt/persistent
Apache 2.0 - See LICENSE file for details