NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 86% CSS 10% SQL 3% Shell 1% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/anord/ledger.git
ssh://[email protected]:2222/orgs/anord/ledger.git

Commit

Add first-run setup and gate demo seed behind config

462c463
Alex Nord <[email protected]> 3 months ago
.env.example                      |   1 +
 README.md                         |  29 +++++-
 apps/api/src/app.ts               |   2 +
 apps/api/src/config/env.ts        |   5 +-
 apps/api/src/db/seed.ts           | 199 ++++++++++++++++++++------------------
 apps/api/src/http/routes/auth.ts  |   5 +
 apps/api/src/http/routes/setup.ts |  34 +++++++
 apps/api/src/services/setup.ts    | 114 ++++++++++++++++++++++
 apps/api/src/test/http.test.ts    |  17 ++++
 apps/web/src/App.tsx              | 167 +++++++++++++++++++++++++++++++-
 apps/web/src/styles.css           |  10 +-
 apps/worker/src/index.ts          |   4 +-
 docker-compose.yml                |  53 +++++++---
 infra/scripts/dev-bootstrap.sh    |  12 +++
 14 files changed, 534 insertions(+), 118 deletions(-)
 create mode 100644 apps/api/src/http/routes/setup.ts
 create mode 100644 apps/api/src/services/setup.ts
 create mode 100644 infra/scripts/dev-bootstrap.sh

Diff

diff --git a/.env.example b/.env.example
index 0b1a019..9543fa2 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,7 @@ POSTGRES_PASSWORD=ledger
 DATABASE_URL=postgresql://ledger:ledger@postgres:5432/ledger
 
 REDIS_URL=redis://redis:6379
+LEDGER_ENABLE_DEMO_SEED=false
 
 JWT_SECRET=change-me
 SESSION_COOKIE_NAME=ledger_session
diff --git a/README.md b/README.md
index 028f0d0..4476c78 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,19 @@ This repository currently ships a real MVP foundation:
 - Docker Compose for local development
 - Basic tests for permission logic, markdown sanitization, auth helpers, search matching, webhook signing, and key HTTP flows
 
+## Fresh install behavior
+
+Ledger no longer assumes demo accounts on a normal install.
+
+- `docker compose up` now runs a single `bootstrap` container that installs dependencies once into a shared Docker volume
+- PostgreSQL and Redis stay internal to the Compose network by default
+- The first browser visit takes you to a setup screen where you create:
+  - the initial owner account
+  - the site name and brand color
+  - the footer text
+  - the initial public knowledge base toggle
+- Demo users and sample content are only created when `LEDGER_ENABLE_DEMO_SEED=true`
+
 ## Monorepo layout
 
 - `apps/web`: React frontend
@@ -60,8 +73,10 @@ Services:
 
 - Frontend: [http://localhost:5173](http://localhost:5173)
 - API: [http://localhost:4000](http://localhost:4000)
-- Postgres: `localhost:5432`
-- Redis: `localhost:6379`
+- Postgres: internal-only in Docker Compose
+- Redis: internal-only in Docker Compose
+
+On first boot, open the frontend and complete the setup wizard instead of using a pre-seeded admin login.
 
 ## Without Docker
 
@@ -74,9 +89,15 @@ npm run dev:web
 npm run dev:worker
 ```
 
-## Seeded accounts
+## Optional demo seed
+
+If you want the old demo-style experience with seeded users and pages:
+
+```bash
+LEDGER_ENABLE_DEMO_SEED=true
+```
 
-All seeded users use password `Password123!`.
+All demo users use password `Password123!`.
 
 - `[email protected]`
 - `[email protected]`
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index bb40f68..5e27bfe 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -16,6 +16,7 @@ import { pagesRouter } from "./http/routes/pages.js";
 import { rolesRouter } from "./http/routes/roles.js";
 import { searchRouter } from "./http/routes/search.js";
 import { spacesRouter } from "./http/routes/spaces.js";
+import { setupRouter } from "./http/routes/setup.js";
 import { settingsRouter } from "./http/routes/settings.js";
 import { webhooksRouter } from "./http/routes/webhooks.js";
 import { integrationsRouter } from "./http/routes/integrations.js";
@@ -66,6 +67,7 @@ export function createApp() {
   app.use("/api/roles", rolesRouter);
   app.use("/api/feedback", feedbackRouter);
   app.use("/api/settings", settingsRouter);
+  app.use("/api/setup", setupRouter);
   app.use("/api/admin", adminRouter);
   app.use("/api/webhooks", webhooksRouter);
   app.use("/api/integrations", integrationsRouter);
diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts
index 5c44dc0..908fedf 100644
--- a/apps/api/src/config/env.ts
+++ b/apps/api/src/config/env.ts
@@ -7,6 +7,10 @@ const envSchema = z.object({
   LEDGER_APP_URL: z.string().url().default("http://localhost:5173"),
   DATABASE_URL: z.string().default("postgresql://ledger:ledger@localhost:5432/ledger"),
   REDIS_URL: z.string().default("redis://localhost:6379"),
+  LEDGER_ENABLE_DEMO_SEED: z
+    .enum(["true", "false"])
+    .default("false")
+    .transform((value) => value === "true"),
   JWT_SECRET: z.string().min(8).default("change-me"),
   SESSION_COOKIE_NAME: z.string().default("ledger_session"),
   PASSWORD_PEPPER: z.string().min(4).default("change-me"),
@@ -36,4 +40,3 @@ const envSchema = z.object({
 
 export const env = envSchema.parse(process.env);
 export const isProduction = env.NODE_ENV === "production";
-
diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts
index f891e9a..ddce7c7 100644
--- a/apps/api/src/db/seed.ts
+++ b/apps/api/src/db/seed.ts
@@ -1,3 +1,4 @@
+import { env } from "../config/env.js";
 import { hashPassword } from "../services/auth.js";
 import { pool } from "./pool.js";
 
@@ -22,100 +23,6 @@ async function main() {
      ON CONFLICT (name) DO NOTHING`
   );
 
-  const roles = await pool.query(`SELECT id, key FROM roles`);
-  const roleMap = new Map(roles.rows.map((row) => [row.key, row.id]));
-
-  const users = [
-    ["[email protected]", "Ledger Owner", "owner"],
-    ["[email protected]", "Ledger Admin", "admin"],
-    ["[email protected]", "Ledger Editor", "editor"],
-    ["[email protected]", "Ledger Viewer", "viewer"]
-  ];
-
-  for (const [email, displayName, roleKey] of users) {
-    await pool.query(
-      `
-        INSERT INTO users (email, password_hash, display_name, primary_role_id)
-        VALUES ($1, $2, $3, $4)
-        ON CONFLICT (email) DO NOTHING
-      `,
-      [email, await hashPassword("Password123!"), displayName, roleMap.get(roleKey)]
-    );
-  }
-
-  const owner = await pool.query(`SELECT id FROM users WHERE email = '[email protected]'`);
-  const editor = await pool.query(`SELECT id FROM users WHERE email = '[email protected]'`);
-  const engineeringGroup = await pool.query(`SELECT id FROM groups WHERE name = 'engineering'`);
-
-  await pool.query(
-    `INSERT INTO group_memberships (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
-    [editor.rows[0].id, engineeringGroup.rows[0].id]
-  );
-
-  await pool.query(
-    `
-      INSERT INTO spaces (name, key, description, visibility, created_by_user_id)
-      VALUES
-        ('Docs', 'docs', 'Public product documentation', 'public', $1),
-        ('Ops', 'ops', 'Internal runbooks and procedures', 'internal', $1)
-      ON CONFLICT (key) DO NOTHING
-    `,
-    [owner.rows[0].id]
-  );
-
-  const docsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'docs'`);
-  const opsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'ops'`);
-
-  const existingPublic = await pool.query(`SELECT id FROM pages WHERE slug = 'welcome-to-ledger'`);
-  if (!existingPublic.rowCount) {
-    const page = await pool.query(
-      `
-        INSERT INTO pages (space_id, title, slug, excerpt, visibility, state, is_public, owner_user_id)
-        VALUES ($1, 'Welcome to Ledger', 'welcome-to-ledger', 'Ledger helps teams publish trusted answers.', 'public', 'published', true, $2)
-        RETURNING id
-      `,
-      [docsSpace.rows[0].id, owner.rows[0].id]
-    );
-
-    const revision = await pool.query(
-      `
-        INSERT INTO page_revisions (page_id, title, slug, excerpt, visibility, state, body_markdown, edited_by_user_id)
-        VALUES ($1, 'Welcome to Ledger', 'welcome-to-ledger', 'Ledger helps teams publish trusted answers.', 'public', 'published', '# Welcome to Ledger\n\nLedger is a secure knowledge base for public docs and internal answers.\n\n## What you can do\n\n- Publish public docs\n- Keep internal pages permission-aware\n- Search across trusted knowledge', $2)
-        RETURNING id
-      `,
-      [page.rows[0].id, owner.rows[0].id]
-    );
-
-    await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [page.rows[0].id, revision.rows[0].id]);
-  }
-
-  const existingInternal = await pool.query(`SELECT id FROM pages WHERE slug = 'incident-runbook'`);
-  if (!existingInternal.rowCount) {
-    const page = await pool.query(
-      `
-        INSERT INTO pages (space_id, title, slug, excerpt, visibility, state, is_public, owner_user_id)
-        VALUES ($1, 'Incident Runbook', 'incident-runbook', 'Internal escalation workflow for incidents.', 'restricted', 'published', false, $2)
-        RETURNING id
-      `,
-      [opsSpace.rows[0].id, owner.rows[0].id]
-    );
-
-    const revision = await pool.query(
-      `
-        INSERT INTO page_revisions (page_id, title, slug, excerpt, visibility, state, body_markdown, edited_by_user_id, tag_snapshot)
-        VALUES ($1, 'Incident Runbook', 'incident-runbook', 'Internal escalation workflow for incidents.', 'restricted', 'published', '# Incident Runbook\n\nUse this page when triaging a Sev-1 incident.\n\n## First 10 minutes\n\n- Declare incident commander\n- Open incident channel\n- Update status page', $2, '["internal","ops"]'::jsonb)
-        RETURNING id
-      `,
-      [page.rows[0].id, owner.rows[0].id]
-    );
-
-    await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [page.rows[0].id, revision.rows[0].id]);
-    await pool.query(`INSERT INTO page_permissions (page_id, group_id) VALUES ($1, $2)`, [
-      page.rows[0].id,
-      engineeringGroup.rows[0].id
-    ]);
-  }
-
   await pool.query(
     `
       INSERT INTO branding_settings (site_name, logo_url, brand_color, footer_text, public_knowledge_base_enabled)
@@ -140,7 +47,109 @@ async function main() {
     `
   );
 
-  console.log("Seeded Ledger demo data");
+  if (env.LEDGER_ENABLE_DEMO_SEED) {
+    const roles = await pool.query(`SELECT id, key FROM roles`);
+    const roleMap = new Map(roles.rows.map((row) => [row.key, row.id]));
+
+    const users = [
+      ["[email protected]", "Ledger Owner", "owner"],
+      ["[email protected]", "Ledger Admin", "admin"],
+      ["[email protected]", "Ledger Editor", "editor"],
+      ["[email protected]", "Ledger Viewer", "viewer"]
+    ];
+
+    for (const [email, displayName, roleKey] of users) {
+      await pool.query(
+        `
+          INSERT INTO users (email, password_hash, display_name, primary_role_id)
+          VALUES ($1, $2, $3, $4)
+          ON CONFLICT (email) DO NOTHING
+        `,
+        [email, await hashPassword("Password123!"), displayName, roleMap.get(roleKey)]
+      );
+    }
+
+    const owner = await pool.query(`SELECT id FROM users WHERE email = '[email protected]'`);
+    const editor = await pool.query(`SELECT id FROM users WHERE email = '[email protected]'`);
+    const engineeringGroup = await pool.query(`SELECT id FROM groups WHERE name = 'engineering'`);
+
+    await pool.query(
+      `INSERT INTO group_memberships (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
+      [editor.rows[0].id, engineeringGroup.rows[0].id]
+    );
+
+    await pool.query(
+      `
+        INSERT INTO spaces (name, key, description, visibility, created_by_user_id)
+        VALUES
+          ('Docs', 'docs', 'Public product documentation', 'public', $1),
+          ('Ops', 'ops', 'Internal runbooks and procedures', 'internal', $1)
+        ON CONFLICT (key) DO NOTHING
+      `,
+      [owner.rows[0].id]
+    );
+
+    const docsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'docs'`);
+    const opsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'ops'`);
+
+    const existingPublic = await pool.query(`SELECT id FROM pages WHERE slug = 'welcome-to-ledger'`);
+    if (!existingPublic.rowCount) {
+      const page = await pool.query(
+        `
+          INSERT INTO pages (space_id, title, slug, excerpt, visibility, state, is_public, owner_user_id)
+          VALUES ($1, 'Welcome to Ledger', 'welcome-to-ledger', 'Ledger helps teams publish trusted answers.', 'public', 'published', true, $2)
+          RETURNING id
+        `,
+        [docsSpace.rows[0].id, owner.rows[0].id]
+      );
+
+      const revision = await pool.query(
+        `
+          INSERT INTO page_revisions (page_id, title, slug, excerpt, visibility, state, body_markdown, edited_by_user_id)
+          VALUES ($1, 'Welcome to Ledger', 'welcome-to-ledger', 'Ledger helps teams publish trusted answers.', 'public', 'published', '# Welcome to Ledger\n\nLedger is a secure knowledge base for public docs and internal answers.\n\n## What you can do\n\n- Publish public docs\n- Keep internal pages permission-aware\n- Search across trusted knowledge', $2)
+          RETURNING id
+        `,
+        [page.rows[0].id, owner.rows[0].id]
+      );
+
+      await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [
+        page.rows[0].id,
+        revision.rows[0].id
+      ]);
+    }
+
+    const existingInternal = await pool.query(`SELECT id FROM pages WHERE slug = 'incident-runbook'`);
+    if (!existingInternal.rowCount) {
+      const page = await pool.query(
+        `
+          INSERT INTO pages (space_id, title, slug, excerpt, visibility, state, is_public, owner_user_id)
+          VALUES ($1, 'Incident Runbook', 'incident-runbook', 'Internal escalation workflow for incidents.', 'restricted', 'published', false, $2)
+          RETURNING id
+        `,
+        [opsSpace.rows[0].id, owner.rows[0].id]
+      );
+
+      const revision = await pool.query(
+        `
+          INSERT INTO page_revisions (page_id, title, slug, excerpt, visibility, state, body_markdown, edited_by_user_id, tag_snapshot)
+          VALUES ($1, 'Incident Runbook', 'incident-runbook', 'Internal escalation workflow for incidents.', 'restricted', 'published', '# Incident Runbook\n\nUse this page when triaging a Sev-1 incident.\n\n## First 10 minutes\n\n- Declare incident commander\n- Open incident channel\n- Update status page', $2, '["internal","ops"]'::jsonb)
+          RETURNING id
+        `,
+        [page.rows[0].id, owner.rows[0].id]
+      );
+
+      await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [
+        page.rows[0].id,
+        revision.rows[0].id
+      ]);
+      await pool.query(`INSERT INTO page_permissions (page_id, group_id) VALUES ($1, $2)`, [
+        page.rows[0].id,
+        engineeringGroup.rows[0].id
+      ]);
+    }
+  }
+
+  console.log(env.LEDGER_ENABLE_DEMO_SEED ? "Seeded Ledger demo data" : "Seeded Ledger base data");
 }
 
 main()
diff --git a/apps/api/src/http/routes/auth.ts b/apps/api/src/http/routes/auth.ts
index d3bccc2..71a2f27 100644
--- a/apps/api/src/http/routes/auth.ts
+++ b/apps/api/src/http/routes/auth.ts
@@ -19,6 +19,11 @@ const registerSchema = z.object({
 export const authRouter = Router();
 
 authRouter.post("/register", async (req, res) => {
+  const userCount = await pool.query(`SELECT COUNT(*)::int AS count FROM users`);
+  if (userCount.rows[0].count === 0) {
+    return res.status(403).json({ error: "Complete initial Ledger setup first" });
+  }
+
   const input = registerSchema.parse(req.body);
   const existing = await pool.query(`SELECT id FROM users WHERE email = $1`, [input.email]);
 
diff --git a/apps/api/src/http/routes/setup.ts b/apps/api/src/http/routes/setup.ts
new file mode 100644
index 0000000..bbac773
--- /dev/null
+++ b/apps/api/src/http/routes/setup.ts
@@ -0,0 +1,34 @@
+import { Router } from "express";
+import { z } from "zod";
+import { env, isProduction } from "../../config/env.js";
+import { initializeLedger, getSetupStatus } from "../../services/setup.js";
+
+const setupSchema = z.object({
+  siteName: z.string().min(2),
+  brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
+  footerText: z.string().max(200).nullable(),
+  publicKnowledgeBaseEnabled: z.boolean(),
+  ownerEmail: z.string().email(),
+  ownerDisplayName: z.string().min(2),
+  password: z.string().min(8)
+});
+
+export const setupRouter = Router();
+
+setupRouter.get("/status", async (_req, res) => {
+  const status = await getSetupStatus();
+  return res.json(status);
+});
+
+setupRouter.post("/initialize", async (req, res) => {
+  const input = setupSchema.parse(req.body);
+  const result = await initializeLedger(input);
+
+  res.cookie(env.SESSION_COOKIE_NAME, result.token, {
+    httpOnly: true,
+    sameSite: "lax",
+    secure: isProduction
+  });
+
+  return res.status(201).json({ user: result.user });
+});
diff --git a/apps/api/src/services/setup.ts b/apps/api/src/services/setup.ts
new file mode 100644
index 0000000..70c9604
--- /dev/null
+++ b/apps/api/src/services/setup.ts
@@ -0,0 +1,114 @@
+import { hashPassword, createSessionToken, getUserForSession } from "./auth.js";
+import { logAudit } from "./audit.js";
+import { pool } from "../db/pool.js";
+
+interface SetupInput {
+  siteName: string;
+  brandColor: string;
+  footerText: string | null;
+  publicKnowledgeBaseEnabled: boolean;
+  ownerEmail: string;
+  ownerDisplayName: string;
+  password: string;
+}
+
+export async function getSetupStatus() {
+  const users = await pool.query(`SELECT COUNT(*)::int AS count FROM users`);
+  const branding = await pool.query(`SELECT site_name, brand_color FROM branding_settings ORDER BY created_at ASC LIMIT 1`);
+
+  return {
+    isInitialized: users.rows[0].count > 0,
+    branding: branding.rows[0] ?? null
+  };
+}
+
+export async function initializeLedger(input: SetupInput) {
+  const existingUsers = await pool.query(`SELECT COUNT(*)::int AS count FROM users`);
+  if (existingUsers.rows[0].count > 0) {
+    throw new Error("Ledger has already been initialized");
+  }
+
+  await pool.query("BEGIN");
+  try {
+    const ownerRole = await pool.query(`SELECT id FROM roles WHERE key = 'owner'`);
+
+    const user = await pool.query(
+      `
+        INSERT INTO users (email, password_hash, display_name, primary_role_id)
+        VALUES ($1, $2, $3, $4)
+        RETURNING id
+      `,
+      [input.ownerEmail, await hashPassword(input.password), input.ownerDisplayName, ownerRole.rows[0].id]
+    );
+
+    const ownerUserId = user.rows[0].id;
+
+    const branding = await pool.query(
+      `
+        UPDATE branding_settings
+        SET site_name = $1, brand_color = $2, footer_text = $3,
+            public_knowledge_base_enabled = $4, updated_at = now()
+        WHERE id = (SELECT id FROM branding_settings ORDER BY created_at ASC LIMIT 1)
+        RETURNING id
+      `,
+      [input.siteName, input.brandColor, input.footerText, input.publicKnowledgeBaseEnabled]
+    );
+
+    await pool.query(
+      `
+        INSERT INTO spaces (name, key, description, visibility, created_by_user_id)
+        VALUES
+          ('Docs', 'docs', 'Public documentation for your organization', 'public', $1),
+          ('Team', 'team', 'Internal knowledge for your organization', 'internal', $1)
+        ON CONFLICT (key) DO NOTHING
+      `,
+      [ownerUserId]
+    );
+
+    const docsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'docs'`);
+    const welcomePage = await pool.query(
+      `
+        INSERT INTO pages (space_id, title, slug, excerpt, visibility, state, is_public, owner_user_id)
+        VALUES ($1, 'Welcome to Ledger', 'welcome-to-ledger', 'Start customizing your new knowledge base.', 'public', 'published', true, $2)
+        RETURNING id
+      `,
+      [docsSpace.rows[0].id, ownerUserId]
+    );
+
+    const welcomeRevision = await pool.query(
+      `
+        INSERT INTO page_revisions (page_id, title, slug, excerpt, visibility, state, body_markdown, edited_by_user_id)
+        VALUES (
+          $1,
+          'Welcome to Ledger',
+          'welcome-to-ledger',
+          'Start customizing your new knowledge base.',
+          'public',
+          'published',
+          '# Welcome to Ledger\n\nYour knowledge base is ready.\n\n## Next steps\n\n- Customize branding\n- Invite your team\n- Publish your first public and internal pages',
+          $2
+        )
+        RETURNING id
+      `,
+      [welcomePage.rows[0].id, ownerUserId]
+    );
+
+    await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [
+      welcomePage.rows[0].id,
+      welcomeRevision.rows[0].id
+    ]);
+
+    await pool.query("COMMIT");
+
+    const sessionUser = await getUserForSession(ownerUserId);
+    const token = createSessionToken(sessionUser!);
+    await logAudit(ownerUserId, "setup.initialize", "ledger", "instance", {
+      brandingSettingsId: branding.rows[0].id
+    });
+
+    return { token, user: sessionUser };
+  } catch (error) {
+    await pool.query("ROLLBACK");
+    throw error;
+  }
+}
diff --git a/apps/api/src/test/http.test.ts b/apps/api/src/test/http.test.ts
index 935c8e4..fd941fe 100644
--- a/apps/api/src/test/http.test.ts
+++ b/apps/api/src/test/http.test.ts
@@ -61,6 +61,23 @@ describe("http flows", () => {
     expect(response.headers["set-cookie"]).toBeTruthy();
   });
 
+  it("blocks register before initial setup is completed", async () => {
+    queryMock.mockResolvedValueOnce({
+      rowCount: 1,
+      rows: [{ count: 0 }]
+    });
+
+    const { createApp } = await import("../app.js");
+    const response = await request(createApp()).post("/api/auth/register").send({
+      email: "[email protected]",
+      password: "Password123!",
+      displayName: "First User"
+    });
+
+    expect(response.status).toBe(403);
+    expect(response.body.error).toContain("initial Ledger setup");
+  });
+
   it("creates feedback records", async () => {
     queryMock
       .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: "feedback-1" }] })
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index ea6b07e..a937641 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -10,12 +10,21 @@ import { api } from "./lib/api";
 type BrandingResponse = {
   branding: {
     siteName: string;
+    logoUrl?: string | null;
     brandColor: string;
     footerText: string | null;
     publicKnowledgeBaseEnabled: boolean;
   };
 };
 
+type SetupStatus = {
+  isInitialized: boolean;
+  branding: {
+    site_name: string;
+    brand_color: string;
+  } | null;
+};
+
 function useSession() {
   const [user, setUser] = useState<SessionUser | null>(null);
   const [loading, setLoading] = useState(true);
@@ -192,8 +201,8 @@ function PageView() {
 
 function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
   const navigate = useNavigate();
-  const [email, setEmail] = useState("[email protected]");
-  const [password, setPassword] = useState("Password123!");
+  const [email, setEmail] = useState("");
+  const [password, setPassword] = useState("");
   const [error, setError] = useState<string | null>(null);
 
   async function submit(event: React.FormEvent) {
@@ -227,6 +236,128 @@ function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
   );
 }
 
+function SetupPage({
+  onInitialized,
+  initialBranding
+}: {
+  onInitialized: (user: SessionUser, branding: BrandingResponse["branding"]) => void;
+  initialBranding: SetupStatus["branding"];
+}) {
+  const navigate = useNavigate();
+  const [form, setForm] = useState({
+    siteName: initialBranding?.site_name ?? "Ledger",
+    brandColor: initialBranding?.brand_color ?? "#245cff",
+    footerText: "Built for fast, trusted answers.",
+    publicKnowledgeBaseEnabled: true,
+    ownerEmail: "",
+    ownerDisplayName: "",
+    password: ""
+  });
+  const [status, setStatus] = useState<string | null>(null);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    try {
+      const response = await api.post<{ user: SessionUser }>("/api/setup/initialize", {
+        ...form,
+        footerText: form.footerText || null
+      });
+
+      onInitialized(response.user, {
+        siteName: form.siteName,
+        logoUrl: null,
+        brandColor: form.brandColor,
+        footerText: form.footerText || null,
+        publicKnowledgeBaseEnabled: form.publicKnowledgeBaseEnabled
+      });
+      navigate("/dashboard");
+    } catch (error) {
+      setStatus(error instanceof Error ? error.message : "Could not initialize Ledger");
+    }
+  }
+
+  return (
+    <section className="setup-layout">
+      <div className="hero-card">
+        <p className="eyebrow">First-time setup</p>
+        <h1>Create your Ledger base</h1>
+        <p className="lede">
+          Set your site identity, choose whether public docs are enabled, and create the first
+          owner account. This happens once on a fresh install.
+        </p>
+      </div>
+      <form className="card stack" onSubmit={submit}>
+        <div className="split">
+          <label>
+            Site name
+            <input
+              value={form.siteName}
+              onChange={(event) => setForm((current) => ({ ...current, siteName: event.target.value }))}
+            />
+          </label>
+          <label>
+            Brand color
+            <input
+              value={form.brandColor}
+              onChange={(event) =>
+                setForm((current) => ({ ...current, brandColor: event.target.value }))
+              }
+            />
+          </label>
+        </div>
+        <label>
+          Footer text
+          <input
+            value={form.footerText}
+            onChange={(event) => setForm((current) => ({ ...current, footerText: event.target.value }))}
+          />
+        </label>
+        <label className="checkbox-row">
+          <input
+            type="checkbox"
+            checked={form.publicKnowledgeBaseEnabled}
+            onChange={(event) =>
+              setForm((current) => ({
+                ...current,
+                publicKnowledgeBaseEnabled: event.target.checked
+              }))
+            }
+          />
+          Enable the public knowledge base immediately
+        </label>
+        <div className="split">
+          <label>
+            Owner name
+            <input
+              value={form.ownerDisplayName}
+              onChange={(event) =>
+                setForm((current) => ({ ...current, ownerDisplayName: event.target.value }))
+              }
+            />
+          </label>
+          <label>
+            Owner email
+            <input
+              value={form.ownerEmail}
+              onChange={(event) => setForm((current) => ({ ...current, ownerEmail: event.target.value }))}
+            />
+          </label>
+        </div>
+        <label>
+          Password
+          <input
+            type="password"
+            value={form.password}
+            onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
+          />
+        </label>
+        <button type="submit">Initialize Ledger</button>
+        {status ? <p className="muted">{status}</p> : null}
+      </form>
+    </section>
+  );
+}
+
 function Dashboard({ user }: { user: SessionUser }) {
   const [spaces, setSpaces] = useState<Array<{ id: string; name: string; key: string }>>([]);
   const [analytics, setAnalytics] = useState<{
@@ -387,9 +518,11 @@ function Dashboard({ user }: { user: SessionUser }) {
 export function App() {
   const { user, setUser, loading } = useSession();
   const [branding, setBranding] = useState<BrandingResponse["branding"] | null>(null);
+  const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
 
   useEffect(() => {
     api.get<BrandingResponse>("/api/settings/public").then((response) => setBranding(response.branding));
+    api.get<SetupStatus>("/api/setup/status").then(setSetupStatus);
   }, []);
 
   async function logout() {
@@ -397,10 +530,38 @@ export function App() {
     setUser(null);
   }
 
-  if (loading) {
+  if (loading || !setupStatus) {
     return <div className="loading-screen">Loading Ledger...</div>;
   }
 
+  if (!setupStatus.isInitialized) {
+    return (
+      <Shell branding={branding} user={null} onLogout={logout}>
+        <Routes>
+          <Route
+            path="*"
+            element={
+              <SetupPage
+                initialBranding={setupStatus.branding}
+                onInitialized={(initializedUser, initializedBranding) => {
+                  setUser(initializedUser);
+                  setBranding(initializedBranding);
+                  setSetupStatus({
+                    isInitialized: true,
+                    branding: {
+                      site_name: initializedBranding.siteName,
+                      brand_color: initializedBranding.brandColor
+                    }
+                  });
+                }}
+              />
+            }
+          />
+        </Routes>
+      </Shell>
+    );
+  }
+
   return (
     <Shell branding={branding} user={user} onLogout={logout}>
       <Routes>
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 7e37c95..d2b1263 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -105,7 +105,8 @@ main {
 }
 
 .hero-layout,
-.dashboard-grid {
+.dashboard-grid,
+.setup-layout {
   display: grid;
   gap: 1.25rem;
 }
@@ -114,6 +115,10 @@ main {
   grid-template-columns: 1.2fr 1fr;
 }
 
+.setup-layout {
+  grid-template-columns: 1fr 1.1fr;
+}
+
 .grid,
 .content-layout {
   display: grid;
@@ -285,7 +290,8 @@ main {
 @media (max-width: 900px) {
   .hero-layout,
   .grid,
-  .content-layout {
+  .content-layout,
+  .setup-layout {
     grid-template-columns: 1fr;
   }
 
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index ca56ae1..69f2c5d 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -2,7 +2,9 @@ import "dotenv/config";
 import { Worker } from "bullmq";
 import Redis from "ioredis";
 
-const connection = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
+const connection = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
+  maxRetriesPerRequest: null
+});
 
 const worker = new Worker(
   "ledger-jobs",
diff --git a/docker-compose.yml b/docker-compose.yml
index bdd9441..7f366ac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,27 +1,41 @@
 services:
+  bootstrap:
+    image: node:20-bookworm
+    working_dir: /app
+    command: sh /app/infra/scripts/dev-bootstrap.sh
+    volumes:
+      - .:/app
+      - node_modules:/app/node_modules
+
   postgres:
     image: postgres:16-alpine
     environment:
       POSTGRES_DB: ${POSTGRES_DB:-ledger}
       POSTGRES_USER: ${POSTGRES_USER:-ledger}
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ledger}
-    ports:
-      - "5432:5432"
     volumes:
       - postgres_data:/var/lib/postgresql/data
       - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ledger} -d ${POSTGRES_DB:-ledger}"]
+      interval: 5s
+      timeout: 5s
+      retries: 20
 
   redis:
     image: redis:7-alpine
-    ports:
-      - "6379:6379"
     volumes:
       - redis_data:/data
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      timeout: 5s
+      retries: 20
 
   api:
     image: node:20-bookworm
     working_dir: /app
-    command: sh -c "npm install && npm run db:migrate && npm run db:seed && npm run dev:api"
+    command: sh -c "npm run db:migrate && npm run db:seed && npm --workspace @ledger/api run dev"
     env_file:
       - .env
     ports:
@@ -29,35 +43,50 @@ services:
     volumes:
       - .:/app
       - ./storage/uploads:/app/storage/uploads
+      - node_modules:/app/node_modules
     depends_on:
-      - postgres
-      - redis
+      bootstrap:
+        condition: service_completed_successfully
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
 
   web:
     image: node:20-bookworm
     working_dir: /app
-    command: sh -c "npm install && npm run dev:web -- --host 0.0.0.0"
+    command: npm --workspace @ledger/web run dev -- --host 0.0.0.0
     env_file:
       - .env
     ports:
       - "5173:5173"
     volumes:
       - .:/app
+      - node_modules:/app/node_modules
     depends_on:
-      - api
+      bootstrap:
+        condition: service_completed_successfully
+      api:
+        condition: service_started
 
   worker:
     image: node:20-bookworm
     working_dir: /app
-    command: sh -c "npm install && npm run dev:worker"
+    command: npm --workspace @ledger/worker run dev
     env_file:
       - .env
     volumes:
       - .:/app
+      - node_modules:/app/node_modules
     depends_on:
-      - api
-      - redis
+      bootstrap:
+        condition: service_completed_successfully
+      api:
+        condition: service_started
+      redis:
+        condition: service_healthy
 
 volumes:
   postgres_data:
   redis_data:
+  node_modules:
diff --git a/infra/scripts/dev-bootstrap.sh b/infra/scripts/dev-bootstrap.sh
new file mode 100644
index 0000000..ee6d7e9
--- /dev/null
+++ b/infra/scripts/dev-bootstrap.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -eu
+
+cd /app
+
+mkdir -p /app/node_modules
+
+if [ ! -f /app/node_modules/.ledger-ready ]; then
+  npm install
+  npm run build --workspace @ledger/shared
+  touch /app/node_modules/.ledger-ready
+fi