NixOS Flake Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Create a Nix flake that builds the atBB monorepo and defines a NixOS module for deploying appview + web as systemd services behind nginx.
Architecture: The flake exports a package derivation (builds the pnpm monorepo via pnpm_9.fetchDeps + pnpm_9.configHook) and a NixOS module (services.atbb) that wires up two systemd services, an nginx virtualHost with ACME, and optional local PostgreSQL. Secrets stay out of /nix/store via systemd EnvironmentFile.
Tech Stack: Nix flakes, nixpkgs pnpm_9.fetchDeps/configHook, NixOS module system, systemd, services.nginx, services.postgresql.
Design doc: docs/plans/2026-02-20-nixos-flake-design.md
Task 1: Create flake.nix skeleton#
Files:
- Create:
flake.nix
Step 1: Write flake.nix
{
description = "atBB — decentralized BB-style forum on AT Protocol";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.callPackage ./nix/package.nix { };
}
);
nixosModules.default = import ./nix/module.nix self;
};
}
Step 2: Create nix/ directory
mkdir -p nix
Step 3: Create placeholder nix/package.nix
Create nix/package.nix with a minimal derivation so nix flake check can parse the flake:
# Placeholder — will be implemented in Task 2
{ stdenv, ... }:
stdenv.mkDerivation {
pname = "atbb";
version = "0.1.0";
src = ../.;
installPhase = "mkdir -p $out";
}
Step 4: Create placeholder nix/module.nix
Create nix/module.nix with a minimal module so the flake parses:
# Placeholder — will be implemented in Task 3
self:
{ config, lib, pkgs, ... }:
{
options.services.atbb = {
enable = lib.mkEnableOption "atBB forum";
};
}
Step 5: Verify flake parses
nix flake check --no-build
Expected: No errors. The flake structure is valid.
Step 6: Commit
git add flake.nix nix/package.nix nix/module.nix
git commit -m "chore: scaffold Nix flake with placeholder package and module"
Task 2: Implement package derivation#
Files:
- Modify:
nix/package.nix
The package derivation builds the full pnpm monorepo and copies runtime artifacts to $out.
Key concepts:
pnpm_9.fetchDepscreates a fixed-output derivation (FOD) containing the pnpm content-addressable store. This runs with network access.pnpm_9.configHookis a setup hook that configures pnpm to use the fetched store and runspnpm install --offline --frozen-lockfilein theconfigurePhase.- After
configurePhase, the build sandbox has no network — everything is offline. - pnpm's workspace symlinks point into the Nix store (absolute paths to the FOD), so they survive copying to
$out.
Step 1: Write the full package derivation
Replace nix/package.nix with:
{
lib,
stdenv,
nodejs_22,
pnpm_9,
bash,
}:
stdenv.mkDerivation (finalAttrs: {
pname = "atbb";
version = "0.1.0";
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.unions [
../package.json
../pnpm-lock.yaml
../pnpm-workspace.yaml
../turbo.json
../tsconfig.base.json
../apps
../packages
];
};
pnpmDeps = pnpm_9.fetchDeps {
inherit (finalAttrs) pname version src;
# Set to lib.fakeHash initially, then update after first build attempt
hash = lib.fakeHash;
};
nativeBuildInputs = [
nodejs_22
pnpm_9.configHook
bash
];
buildPhase = ''
runHook preBuild
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
# Workspace config
cp package.json pnpm-lock.yaml pnpm-workspace.yaml $out/
# Root node_modules (pnpm virtual store — symlinks point into Nix store FOD)
cp -r node_modules $out/
# Each workspace package: package.json + dist/ + local node_modules symlinks
for pkg in apps/appview apps/web packages/db packages/atproto packages/cli packages/lexicon; do
mkdir -p "$out/$pkg"
cp "$pkg/package.json" "$out/$pkg/"
[ -d "$pkg/dist" ] && cp -r "$pkg/dist" "$out/$pkg/"
[ -d "$pkg/node_modules" ] && cp -r "$pkg/node_modules" "$out/$pkg/"
done
# Drizzle migrations (needed for db:migrate at deploy time)
cp -r apps/appview/drizzle $out/apps/appview/
# drizzle.config.ts (needed by drizzle-kit migrate)
cp apps/appview/drizzle.config.ts $out/apps/appview/
# Web static assets (CSS, favicon — served by hono serveStatic)
cp -r apps/web/public $out/apps/web/
runHook postInstall
'';
meta = with lib; {
description = "atBB — decentralized BB-style forum on AT Protocol";
license = licenses.agpl3Only;
platforms = [ "x86_64-linux" "aarch64-linux" ];
};
})
Step 2: Build to get the real pnpm hash
nix build 2>&1 | grep 'got:'
Expected: Build fails with a hash mismatch. Copy the got: sha256-XXXX... value.
Step 3: Update the hash
Replace lib.fakeHash in nix/package.nix with the real hash from step 2:
hash = "sha256-<paste real hash here>";
Step 4: Build again to verify
nix build
Expected: Build succeeds. Check the output:
ls result/apps/appview/dist/
ls result/apps/web/dist/
ls result/apps/appview/drizzle/
ls result/apps/web/public/
All directories should exist with built artifacts.
Step 5: Commit
git add nix/package.nix
git commit -m "feat(nix): implement package derivation with pnpm workspace build"
Task 3: Implement NixOS module — option declarations#
Files:
- Modify:
nix/module.nix
Step 1: Write all option declarations
Replace nix/module.nix with the full options block:
self:
{ config, lib, pkgs, ... }:
let
cfg = config.services.atbb;
nodejs = pkgs.nodejs_22;
in
{
options.services.atbb = {
enable = lib.mkEnableOption "atBB forum";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.default;
defaultText = lib.literalExpression "self.packages.\${pkgs.system}.default";
description = "The atBB package to use.";
};
domain = lib.mkOption {
type = lib.types.str;
description = "Domain name for the forum (e.g., forum.example.com).";
};
enableNginx = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to configure nginx as a reverse proxy.";
};
enableACME = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable ACME (Let's Encrypt) for TLS.";
};
oauthPublicUrl = lib.mkOption {
type = lib.types.str;
default = "https://${cfg.domain}";
defaultText = lib.literalExpression ''"https://\${cfg.domain}"'';
description = "Public URL for OAuth client metadata. Defaults to https://<domain>.";
};
forumDid = lib.mkOption {
type = lib.types.str;
description = "The forum's AT Protocol DID.";
};
pdsUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the forum's PDS.";
};
environmentFile = lib.mkOption {
type = lib.types.path;
description = ''
Path to an environment file containing secrets.
Must define: DATABASE_URL, SESSION_SECRET, FORUM_HANDLE, FORUM_PASSWORD.
When database.enable = true, DATABASE_URL should be:
postgres:///atbb?host=/run/postgresql (peer auth via Unix socket)
'';
};
database = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to configure a local PostgreSQL 17 instance.";
};
name = lib.mkOption {
type = lib.types.str;
default = "atbb";
description = "Name of the PostgreSQL database.";
};
};
appviewPort = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port for the appview API server (internal, behind nginx).";
};
webPort = lib.mkOption {
type = lib.types.port;
default = 3001;
description = "Port for the web UI server (internal, behind nginx).";
};
seedDefaultRoles = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to seed default roles on appview startup.";
};
autoMigrate = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to automatically run database migrations before starting appview.
When false, run migrations manually: systemctl start atbb-migrate
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "atbb";
description = "System user to run atBB services.";
};
group = lib.mkOption {
type = lib.types.str;
default = "atbb";
description = "System group to run atBB services.";
};
};
# config section will be added in Task 4
config = lib.mkIf cfg.enable { };
}
Step 2: Verify flake parses
nix flake check --no-build
Expected: No errors.
Step 3: Commit
git add nix/module.nix
git commit -m "feat(nix): add NixOS module option declarations for services.atbb"
Task 4: Implement NixOS module — systemd services#
Files:
- Modify:
nix/module.nix(theconfigblock)
Step 1: Add user/group, PostgreSQL, and systemd services to the config block
Replace the config = lib.mkIf cfg.enable { }; line with:
config = lib.mkIf cfg.enable {
# ── System user ──────────────────────────────────────────────
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
description = "atBB service user";
};
users.groups.${cfg.group} = { };
# ── PostgreSQL ───────────────────────────────────────────────
services.postgresql = lib.mkIf cfg.database.enable {
enable = true;
package = pkgs.postgresql_17;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.user;
ensureDBOwnership = true;
}];
};
# ── Database migration (oneshot) ─────────────────────────────
systemd.services.atbb-migrate = {
description = "atBB database migration";
after = [ "network.target" ]
++ lib.optional cfg.database.enable "postgresql.service";
requires = lib.optional cfg.database.enable "postgresql.service";
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "${cfg.package}/apps/appview";
ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate";
EnvironmentFile = cfg.environmentFile;
RemainAfterExit = true;
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
};
};
# ── AppView API server ───────────────────────────────────────
systemd.services.atbb-appview = {
description = "atBB AppView API server";
after = [ "network.target" ]
++ lib.optional cfg.database.enable "postgresql.service"
++ lib.optional cfg.autoMigrate "atbb-migrate.service";
requires = lib.optionals cfg.database.enable [ "postgresql.service" ]
++ lib.optional cfg.autoMigrate "atbb-migrate.service";
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
PORT = toString cfg.appviewPort;
FORUM_DID = cfg.forumDid;
PDS_URL = cfg.pdsUrl;
OAUTH_PUBLIC_URL = cfg.oauthPublicUrl;
SEED_DEFAULT_ROLES = lib.boolToString cfg.seedDefaultRoles;
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "${cfg.package}/apps/appview";
ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/appview/dist/index.js";
EnvironmentFile = cfg.environmentFile;
Restart = "on-failure";
RestartSec = 5;
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
};
# ── Web UI server ────────────────────────────────────────────
systemd.services.atbb-web = {
description = "atBB Web UI server";
after = [ "atbb-appview.service" ];
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
WEB_PORT = toString cfg.webPort;
APPVIEW_URL = "http://localhost:${toString cfg.appviewPort}";
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = "${cfg.package}/apps/web";
ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/web/dist/index.js";
Restart = "on-failure";
RestartSec = 5;
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
};
};
Step 2: Verify flake parses
nix flake check --no-build
Expected: No errors.
Step 3: Commit
git add nix/module.nix
git commit -m "feat(nix): add systemd services and PostgreSQL to NixOS module"
Task 5: Implement NixOS module — nginx virtualHost#
Files:
- Modify:
nix/module.nix(add nginx config to theconfigblock)
Step 1: Add nginx configuration inside the config = lib.mkIf cfg.enable { ... } block
Add these blocks after the systemd.services.atbb-web block, still inside the config = lib.mkIf cfg.enable { ... }:
# ── Nginx reverse proxy ──────────────────────────────────────
services.nginx = lib.mkIf cfg.enableNginx {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
virtualHosts.${cfg.domain} = {
forceSSL = cfg.enableACME;
enableACME = cfg.enableACME;
locations."/.well-known/" = {
proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}";
recommendedProxySettings = true;
};
locations."/api/" = {
proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}";
recommendedProxySettings = true;
};
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.webPort}";
recommendedProxySettings = true;
};
};
};
Step 2: Verify flake parses
nix flake check --no-build
Expected: No errors.
Step 3: Commit
git add nix/module.nix
git commit -m "feat(nix): add nginx virtualHost with ACME to NixOS module"
Task 6: Add .gitignore entry for result symlink and final verification#
Files:
- Modify:
.gitignore
Step 1: Add result to .gitignore
nix build creates a result symlink in the project root. Add it to .gitignore:
# Nix build output
result
Step 2: Run full flake check
nix flake check --no-build
Expected: No errors.
Step 3: Verify the build still succeeds
nix build
Expected: Build succeeds. The result/ symlink points to the built atBB package.
Step 4: Verify runtime artifacts
# Check appview dist
ls result/apps/appview/dist/index.js
# Check web dist
ls result/apps/web/dist/index.js
# Check drizzle migrations
ls result/apps/appview/drizzle/
# Check static assets
ls result/apps/web/public/static/
# Check node_modules resolve
ls result/node_modules/.pnpm/
All paths should exist.
Step 5: Commit
git add .gitignore
git commit -m "chore: add Nix result symlink to .gitignore"
Troubleshooting Reference#
pnpm.fetchDeps hash mismatch#
If pnpm-lock.yaml changes (new dependency, lockfile update), the hash in nix/package.nix must be updated. Set to lib.fakeHash, run nix build, copy the new hash.
configHook install failures#
pnpm_9.configHook runs pnpm install --offline --frozen-lockfile --ignore-script in configurePhase. If a package needs postinstall scripts with native binaries, you may need to add preBuild hooks to patch them.
Lexicon build needs bash glob expansion#
The lexicon build script uses bash -c 'shopt -s globstar && ...'. The bash in nativeBuildInputs provides this. If the build fails with glob errors, verify bash is listed.
drizzle-kit migrate needs TypeScript#
drizzle.config.ts is a TypeScript file. drizzle-kit includes its own TS transpiler, so tsx is not required at runtime. The config file references ../../packages/db/src/schema.ts — this path resolves relative to WorkingDirectory in the systemd service.
NixOS module consumption#
The consumer must set security.acme.acceptTerms = true and security.acme.defaults.email when using enableACME = true. The module does not force these values.