Add a Nix flake with a package and a NixOS module #2

Merged
purplenoodlesoop merged 5 commits from nix-flake into main 2026-10-02 18:43:55 +00:00
purplenoodlesoop commented 2026-10-02 03:59:18 +00:00 (Migrated from github.com)

Adds a flake so agentboard can be run and deployed with Nix.

  • packages.<system>.default: builds with buildNpmPackage on Node 24, with dependencies from importNpmLock (no hash to maintain). It runs node --test during the build and installs agentboard (the server) and kb (the CLI). nix run github:backmeupplz/agentboard starts the server with its data in ./data, like npm start.
  • nixosModules.default: adds services.agentboard with enable, package, host (default 127.0.0.1), port (default 3000), openFirewall and environmentFile (for ADMIN_EMAIL, ADMIN_PASSWORD and ADMIN_NAME). The default package is built with the host's pkgs. It runs as a hardened DynamicUser systemd unit, with data in /var/lib/agentboard, and gets CAP_NET_BIND_SERVICE only for ports below 1024.
  • overlays.default: adds pkgs.agentboard.
  • checks: the package on x86_64-linux, aarch64-linux and aarch64-darwin, plus a NixOS VM test on Linux with two machines. One runs the defaults (port 3000, no capabilities, setup link in the journal). The other binds port 80 with an environment file written at runtime, after first checking that a unit which cannot start stops at the start limit instead of restarting forever.
  • A short NixOS section in the README.

All Nix files live in nix/ except flake.nix and flake.lock, and the flake's only input is nixpkgs. Nothing outside Nix changes, apart from the README and a result line in .gitignore.

Tested: nix build on aarch64-darwin with the sandbox on, and nix flake check (including the VM test) on x86_64-linux.

🤖 Generated with Claude Code

Adds a flake so agentboard can be run and deployed with Nix. - **`packages.<system>.default`**: builds with `buildNpmPackage` on Node 24, with dependencies from `importNpmLock` (no hash to maintain). It runs `node --test` during the build and installs `agentboard` (the server) and `kb` (the CLI). `nix run github:backmeupplz/agentboard` starts the server with its data in `./data`, like `npm start`. - **`nixosModules.default`**: adds `services.agentboard` with `enable`, `package`, `host` (default `127.0.0.1`), `port` (default `3000`), `openFirewall` and `environmentFile` (for `ADMIN_EMAIL`, `ADMIN_PASSWORD` and `ADMIN_NAME`). The default package is built with the host's `pkgs`. It runs as a hardened `DynamicUser` systemd unit, with data in `/var/lib/agentboard`, and gets `CAP_NET_BIND_SERVICE` only for ports below 1024. - **`overlays.default`**: adds `pkgs.agentboard`. - **`checks`**: the package on x86_64-linux, aarch64-linux and aarch64-darwin, plus a NixOS VM test on Linux with two machines. One runs the defaults (port 3000, no capabilities, setup link in the journal). The other binds port 80 with an environment file written at runtime, after first checking that a unit which cannot start stops at the start limit instead of restarting forever. - A short **NixOS** section in the README. All Nix files live in `nix/` except `flake.nix` and `flake.lock`, and the flake's only input is nixpkgs. Nothing outside Nix changes, apart from the README and a `result` line in `.gitignore`. Tested: `nix build` on aarch64-darwin with the sandbox on, and `nix flake check` (including the VM test) on x86_64-linux. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
backmeupplz (Migrated from github.com) reviewed 2026-10-02 03:59:18 +00:00
backmeupplz (Migrated from github.com) requested changes 2026-10-02 13:58:48 +00:00
backmeupplz (Migrated from github.com) left a comment

Thanks for adding Nix support! I left some comments inline. The main fixes needed before merge:

  1. Have the module's default package use the host's pkgs, not self.packages.
  2. Set __darwinAllowLocalNetworking so the check passes under the Darwin sandbox.
  3. Remove the hand-maintained npmDepsHash (importNpmLock) or add a CI job that builds the flake.

The rest are smaller module fixes (low ports, openFirewall with a localhost host, pathWith compatibility, network-online) plus a request to drop the core-flake input. Could you address these?

Thanks for adding Nix support! I left some comments inline. The main fixes needed before merge: 1. Have the module's default `package` use the host's `pkgs`, not `self.packages`. 2. Set `__darwinAllowLocalNetworking` so the check passes under the Darwin sandbox. 3. Remove the hand-maintained `npmDepsHash` (`importNpmLock`) or add a CI job that builds the flake. The rest are smaller module fixes (low ports, `openFirewall` with a localhost `host`, `pathWith` compatibility, `network-online`) plus a request to drop the `core-flake` input. Could you address these?
@ -0,0 +1,42 @@
{
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

core-flake (which brings flake-utils with it) is only used here to map over three systems. Every consumer now locks and fetches two extra inputs from a personal repo, which adds supply-chain surface and indirection (flake.packages vs flake.output.checks, an empty devShell). nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ] does the same in about 10 lines of plain flake.nix. Could you drop the input?

`core-flake` (which brings flake-utils with it) is only used here to map over three systems. Every consumer now locks and fetches two extra inputs from a personal repo, which adds supply-chain surface and indirection (`flake.packages` vs `flake.output.checks`, an empty devShell). `nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ]` does the same in about 10 lines of plain flake.nix. Could you drop the input?
@ -0,0 +1,140 @@
{
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

The default package comes from self.packages.${system}, built from the flake's own pinned nixpkgs. On a host system the flake doesn't list (e.g. riscv64-linux), evaluation fails with attribute missing. On supported hosts it pulls a second nixpkgs/nodejs closure into the system and ignores the host's overlays.

Please default to the host's pkgs instead:

default = pkgs.callPackage ./package.nix { };
The default package comes from `self.packages.${system}`, built from the flake's own pinned nixpkgs. On a host system the flake doesn't list (e.g. riscv64-linux), evaluation fails with `attribute missing`. On supported hosts it pulls a second nixpkgs/nodejs closure into the system and ignores the host's overlays. Please default to the host's pkgs instead: ```nix default = pkgs.callPackage ./package.nix { }; ```
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

With CapabilityBoundingSet = "" and DynamicUser, a port below 1024 can never bind. port = 80 throws EACCES, Restart=on-failure loops until the start limit, and the unit ends up failed. Please either grant CAP_NET_BIND_SERVICE (bounding set + AmbientCapabilities) when cfg.port < 1024, or add an assertion.

With `CapabilityBoundingSet = ""` and `DynamicUser`, a port below 1024 can never bind. `port = 80` throws EACCES, `Restart=on-failure` loops until the start limit, and the unit ends up failed. Please either grant `CAP_NET_BIND_SERVICE` (bounding set + `AmbientCapabilities`) when `cfg.port < 1024`, or add an assertion.
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

pathWith { inStore = false; } only checks the string prefix, so a secret can still reach the store through an /etc symlink, and nix/test.nix does exactly that. Also, lib.types.pathWith only exists in nixpkgs ≥ 25.05. The module is evaluated with the consumer's lib, so importing it on 24.11 fails evaluation even when environmentFile is unset.

I'd use lib.types.nullOr lib.types.str (or path) and spell out in the description that the file shouldn't live in the store.

`pathWith { inStore = false; }` only checks the string prefix, so a secret can still reach the store through an `/etc` symlink, and `nix/test.nix` does exactly that. Also, `lib.types.pathWith` only exists in nixpkgs ≥ 25.05. The module is evaluated with the consumer's `lib`, so importing it on 24.11 fails evaluation even when `environmentFile` is unset. I'd use `lib.types.nullOr lib.types.str` (or `path`) and spell out in the description that the file shouldn't live in the store.
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

If host is a specific LAN/Tailscale IP, the service can start before that address exists. listen() fails with EADDRNOTAVAIL, and with the default 100ms RestartSec it hits StartLimitBurst within a second and stays failed. Please use wants/after = [ "network-online.target" ] and/or set RestartSec = 5.

If `host` is a specific LAN/Tailscale IP, the service can start before that address exists. `listen()` fails with EADDRNOTAVAIL, and with the default 100ms `RestartSec` it hits `StartLimitBurst` within a second and stays failed. Please use `wants`/`after = [ "network-online.target" ]` and/or set `RestartSec = 5`.
@ -0,0 +51,4 @@
description = "Port to listen on.";
};
openFirewall = lib.mkOption {
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

host defaults to 127.0.0.1, so openFirewall = true on its own opens the port while connections are still refused. Please add a warning/assertion for openFirewall && host == "127.0.0.1", or say in the description that host must be changed too.

`host` defaults to `127.0.0.1`, so `openFirewall = true` on its own opens the port while connections are still refused. Please add a warning/assertion for `openFirewall && host == "127.0.0.1"`, or say in the description that `host` must be changed too.
@ -0,0 +1,68 @@
{
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

This hard-coded hash has to be bumped by hand on every package-lock.json change, and no CI job builds the flake (docker.yml only runs npm test). The next dependency bump will silently break nix run github:backmeupplz/agentboard and NixOS rebuilds.

Please either switch to importNpmLock, which needs no hash:

npmDeps = importNpmLock { npmRoot = <src>; };
npmConfigHook = importNpmLock.npmConfigHook;

or add a CI job that runs nix flake check / nix build. A CI job would also catch the fileset (lines 15-26) silently dropping any new file server.js starts reading, and module regressions the Docker CI won't see.

This hard-coded hash has to be bumped by hand on every `package-lock.json` change, and no CI job builds the flake (`docker.yml` only runs `npm test`). The next dependency bump will silently break `nix run github:backmeupplz/agentboard` and NixOS rebuilds. Please either switch to `importNpmLock`, which needs no hash: ```nix npmDeps = importNpmLock { npmRoot = <src>; }; npmConfigHook = importNpmLock.npmConfigHook; ``` or add a CI job that runs `nix flake check` / `nix build`. A CI job would also catch the fileset (lines 15-26) silently dropping any new file `server.js` starts reading, and module regressions the Docker CI won't see.
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

Nit: npm prune --omit=dev does nothing because there are no devDependencies, and NODE_ENV (line 48) is never read by server.js. Both can go.

Nit: `npm prune --omit=dev` does nothing because there are no devDependencies, and `NODE_ENV` (line 48) is never read by `server.js`. Both can go.
@ -0,0 +40,4 @@
doCheck = true;
__darwinAllowLocalNetworking = true;
checkPhase = ''
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

node --test runs test.js, which starts server.js on 127.0.0.1:0 and makes HTTP requests. With the sandbox on (common on nix-darwin/Determinate installs), Darwin blocks loopback networking, so the check fails or hangs. Please add:

__darwinAllowLocalNetworking = true;
`node --test` runs `test.js`, which starts `server.js` on `127.0.0.1:0` and makes HTTP requests. With the sandbox on (common on nix-darwin/Determinate installs), Darwin blocks loopback networking, so the check fails or hangs. Please add: ```nix __darwinAllowLocalNetworking = true; ```
@ -0,0 +1,50 @@
self: {
backmeupplz (Migrated from github.com) commented 2026-10-02 13:58:47 +00:00

environment.etc."agentboard.env".text writes ADMIN_PASSWORD into a world-readable /nix/store file. That's fine for a throwaway VM, but it's the pattern people will copy. Please use something like systemd.tmpfiles / a oneshot that writes the file at runtime, or at least leave a comment saying not to do this in real configs.

`environment.etc."agentboard.env".text` writes `ADMIN_PASSWORD` into a world-readable `/nix/store` file. That's fine for a throwaway VM, but it's the pattern people will copy. Please use something like `systemd.tmpfiles` / a oneshot that writes the file at runtime, or at least leave a comment saying not to do this in real configs.
backmeupplz commented 2026-10-02 18:25:54 +00:00 (Migrated from github.com)

Thanks a lot for this, @purplenoodlesoop! The flake, the module hardening and the VM test are all really appreciated. I did a review pass and have a few findings:

Blocking

  1. nix run crashes on startup (nix/package.nix, the agentboard wrapper). DATA_DIR isn't set, so server.js falls back to path.join(ROOT, 'data'), which is inside the read-only Nix store. mkdirSync then throws EROFS before the server starts listening, so the nix run github:backmeupplz/agentboard usage in the README fails. Suggested fix: add --set-default DATA_DIR data to the wrapper so the path resolves against the cwd.

Should fix
2. The unit restarts forever when startup always fails (nix/module.nix). Restart=on-failure with RestartSec=5 never reaches systemd's default start limit (5 starts per 10s). An ADMIN_PASSWORD under 8 chars or a mistyped environmentFile path makes the unit restart every 5s indefinitely. Setting StartLimitBurst / StartLimitIntervalSec in unitConfig would let it end up failed instead.
3. x86_64-darwin is missing from the flake systems (flake.nix), even though meta.platforms includes it. On Intel Macs, nix run/nix build fails with "does not provide attribute packages.x86_64-darwin".
4. network-online.target isn't needed (nix/module.nix). The service binds to loopback by default and makes no outbound connections, so it can delay boot on hosts that have a wait-online service. after = [ "network.target" ] is enough.

Nits
5. The openFirewall loopback warning only matches the literal strings 127.0.0.1 / ::1 / localhost. Other 127.x addresses and the expanded form of ::1 slip past (lib.hasPrefix "127." would cover most of these).
6. The list of runtime files now lives in three places: the Dockerfile COPY, the Nix fileset and the install cp. A new runtime file added only to the Dockerfile would fail in the Nix package at runtime.
7. DATA_DIR = "/var/lib/agentboard" repeats what StateDirectory = "agentboard" already implies. %S/agentboard would keep a single source of truth.
8. The VM test only covers port 80 with an env file. A second case with defaults (port 3000, empty CapabilityBoundingSet, no env file, checking the journal for ?setup=) would cover the setup most users will run.

Happy to help with any of these if useful!

Thanks a lot for this, @purplenoodlesoop! The flake, the module hardening and the VM test are all really appreciated. I did a review pass and have a few findings: **Blocking** 1. **`nix run` crashes on startup** (`nix/package.nix`, the `agentboard` wrapper). `DATA_DIR` isn't set, so `server.js` falls back to `path.join(ROOT, 'data')`, which is inside the read-only Nix store. `mkdirSync` then throws `EROFS` before the server starts listening, so the `nix run github:backmeupplz/agentboard` usage in the README fails. Suggested fix: add `--set-default DATA_DIR data` to the wrapper so the path resolves against the cwd. **Should fix** 2. **The unit restarts forever when startup always fails** (`nix/module.nix`). `Restart=on-failure` with `RestartSec=5` never reaches systemd's default start limit (5 starts per 10s). An `ADMIN_PASSWORD` under 8 chars or a mistyped `environmentFile` path makes the unit restart every 5s indefinitely. Setting `StartLimitBurst` / `StartLimitIntervalSec` in `unitConfig` would let it end up failed instead. 3. **`x86_64-darwin` is missing from the flake systems** (`flake.nix`), even though `meta.platforms` includes it. On Intel Macs, `nix run`/`nix build` fails with "does not provide attribute packages.x86_64-darwin". 4. **`network-online.target` isn't needed** (`nix/module.nix`). The service binds to loopback by default and makes no outbound connections, so it can delay boot on hosts that have a wait-online service. `after = [ "network.target" ]` is enough. **Nits** 5. The `openFirewall` loopback warning only matches the literal strings `127.0.0.1` / `::1` / `localhost`. Other `127.x` addresses and the expanded form of `::1` slip past (`lib.hasPrefix "127."` would cover most of these). 6. The list of runtime files now lives in three places: the Dockerfile COPY, the Nix fileset and the install `cp`. A new runtime file added only to the Dockerfile would fail in the Nix package at runtime. 7. `DATA_DIR = "/var/lib/agentboard"` repeats what `StateDirectory = "agentboard"` already implies. `%S/agentboard` would keep a single source of truth. 8. The VM test only covers port 80 with an env file. A second case with defaults (port 3000, empty `CapabilityBoundingSet`, no env file, checking the journal for `?setup=`) would cover the setup most users will run. Happy to help with any of these if useful!
purplenoodlesoop commented 2026-10-02 18:37:27 +00:00 (Migrated from github.com)

Thanks for the thorough pass! Everything except 3 is addressed in the latest commit. Two notes:

3. x86_64-darwin. Leaving it out on purpose. nixpkgs dropped x86_64-darwin in 26.11, and evaluating legacyPackages.x86_64-darwin on the pinned nixos-unstable aborts with a pointer to the release notes. Listing it would break nix flake show and nix flake check for everyone. nodejs_24.meta.platforms doesn't include it either (builtins.elem "x86_64-darwin" nodejs_24.meta.platforms is false).

4. network-online.target. You're right for the default. The unit now pulls in network-online.target only when host is a specific address (a LAN or Tailscale IP), which is the case from the first review where listen() fails with EADDRNOTAVAIL. Loopback and wildcard (0.0.0.0, ::) binds just use After=network.target, so they don't delay boot.

Thanks for the thorough pass! Everything except 3 is addressed in the latest commit. Two notes: **3. x86_64-darwin.** Leaving it out on purpose. nixpkgs dropped x86_64-darwin in 26.11, and evaluating `legacyPackages.x86_64-darwin` on the pinned nixos-unstable aborts with a pointer to the [release notes](https://nixos.org/manual/nixpkgs/unstable/release-notes#x86_64-darwin-26.11). Listing it would break `nix flake show` and `nix flake check` for everyone. `nodejs_24.meta.platforms` doesn't include it either (`builtins.elem "x86_64-darwin" nodejs_24.meta.platforms` is `false`). **4. network-online.target.** You're right for the default. The unit now pulls in `network-online.target` only when `host` is a specific address (a LAN or Tailscale IP), which is the case from the first review where `listen()` fails with EADDRNOTAVAIL. Loopback and wildcard (`0.0.0.0`, `::`) binds just use `After=network.target`, so they don't delay boot.
Sign in to join this conversation.
No description provided.