WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.fetchDeps creates a fixed-output derivation (FOD) containing the pnpm content-addressable store. This runs with network access.
  • pnpm_9.configHook is a setup hook that configures pnpm to use the fetched store and runs pnpm install --offline --frozen-lockfile in the configurePhase.
  • 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 (the config block)

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 the config block)

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.