Follow the trail.
Crumb is the feedback loop for product teams. Customers drop feedback without leaving your product. You triage it, tie it to the accounts and revenue behind it, ship, and tell them when it's done. One continuous loop, no spreadsheets and no screenshot-and-forget forms.
Built for product and solutions teams tired of watching feedback vanish into a black hole. Open source · cloud or self-hosted.
- Capture in context, not in a form. Customers send a bug, idea, or question from inside your app through an embedded widget, with their account and session already attached. No more "which workspace, which page, which build?"
- Prioritize by revenue, not by volume. Every item is tied to an account and its ARR, so you see the dollars behind a request instead of a raw upvote count.
- Close the loop automatically. Reply in-thread or move an item through your status flow, and the customer hears back, by email or in the widget. It's the step most feedback tools quietly skip.
- A roadmap customers can actually see. Group feedback into initiatives, make them public, and let accounts follow Now / Next / Later and get notified when something they asked for ships.
- Ship to where engineering already lives. Push an item to Linear, Jira, or GitHub as a ticket drafted by AI from the feedback plus your repo context, then sync status back.
- Let AI handle the triage grunt-work. New feedback is auto-clustered into the right initiative, so your inbox starts organizing itself.
- Ask your feedback in plain English. "What do our highest-ARR accounts keep asking for?" gets an answer grounded in the actual submissions, with citations back to them.
Crumb shortens the feedback loop in three steps: from a customer's note to your roadmap to a shipped fix.
A customer hits the embedded widget without leaving your app, shares an idea, and follows the public roadmap.
The feedback lands in your inbox. You answer the customer, then flip the initiative public so every account can follow along.
See the ARR behind each ask, then push it to Linear as a ticket drafted from the feedback and your GitHub repo context.
A polished 90-second cut of all three acts lives at
demos/out/landing.{mp4,webm}.
These are generated from the seeded app, so they stay in sync with the UI. Regenerate the GIFs, screenshots, and landing video with pnpm --filter @crumb/demos all (see demos/README.md). (Images appear once generated and committed.)
crumb/
├── apps/
│ ├── dashboard/ # Next.js dashboard (vendor side) + public /api/v1
│ └── widget/ # The customer-facing embed (vanilla TS → IIFE)
├── packages/
│ ├── db/ # Drizzle schema + Postgres client + seed
│ └── ui/ # Shared design system (icons, atoms, status)
└── demos/ # Playwright → Remotion demo pipeline (GIFs, PNGs, video)
On the roadmap: two-way status sync with engineering trackers (today it's one-way — Crumb reads engineering status back but doesn't push Crumb status changes out) and multiple linked tickets per item.
You need Node 22+, pnpm 11+, and Docker (for Postgres).
pnpm install
pnpm db:up # boots Postgres 16 in docker on :5432
pnpm db:push # applies the schema
pnpm db:seed # populates one workspace (southbeam) with sample data
pnpm widget:build # builds the embed widget → apps/dashboard/public/widget.js
cp apps/dashboard/.env.local.example apps/dashboard/.env.local
pnpm dev # → http://localhost:3000The dashboard requires login — there are two distinct first-run paths, pick one:
- Fresh / empty DB (skip
pnpm db:seed): open http://localhost:3000; with no users yet you're sent to/onboardto create the first workspace + admin. - Seeded demo (
pnpm db:seed): log in at http://localhost:3000/login as a seeded admin (e.g.lina@southbeam.io) — the seed creates thesouthbeamworkspace + sample data. Magic links go to whichever email provider is configured — by default that's stdout (read from the dev terminal ordocker compose logs dashboard); setCRUMB_EMAIL_PROVIDER=resendfor real delivery, see Email delivery. A 7-day session cookie is set; sign out clears it.
Crumb ships from one repo to two deployment shapes — a community build for self-host and a cloud build for the hosted tier:
| Capability | Self-host (default) | Crumb Cloud |
|---|---|---|
| Submit / triage / reply / status flow | ✅ | ✅ |
| Magic-link auth | ✅ | ✅ |
| Embed widget | ✅ | ✅ |
| Inbound email (reply-by-email + forward-to-capture) | ✅ | ✅ |
| Magic-link emails (managed delivery) | stdout only | Resend (or other provider) |
| Vendor-side Slack notifications | BYO Slack app | one-click OAuth · Team plan |
| Microsoft Teams notifications | webhook URL | webhook URL |
| Linear / Jira / GitHub ticket sync | BYO OAuth apps | one-click OAuth · Team plan |
| CRM account + ARR sync (HubSpot / Salesforce) | BYO OAuth apps | one-click OAuth · Team plan |
| Insights — usage analytics & churn signals | ✅ | ✅ (AI-enriched) |
| AI initiative clustering | — (Cloud-only) | Team plan |
| AI ticket drafting (Linear / Jira / GitHub) | — (Cloud-only) | Team plan |
| Ask your feedback (plain-English Q&A) | — (Cloud-only) | Team plan |
| Session Record (rrweb capture + in-thread replay) | — (Cloud-only) | Growth plan |
Self-host is free and AGPL. The hosted tier is live at crumb-app.localhostlabs.net (the marketing site is crumb.localhostlabs.net) — it runs the same source built as the cloud edition, plus the API keys we hold so you don't have to.
Build-time edition vs. runtime tier — two distinct knobs:
CRUMB_EDITION(build-time, defaultcommunity) decides what compiles in. Thecommunitybuild physically excludes the cloud-only code — the Stripe billing UI + webhook, the session-replay APIs, and the heavystripeSDK and cloud AI client — so the self-host image never ships them (those routes 404). Thecloudbuild includes everything. Build withpnpm --filter dashboard build:community(default) orbuild:cloud.CRUMB_TIER(runtime, default self-host) decides behavior within the cloud edition — per-workspace plan gating, managed creds, etc.
The cloud-only route source lives in apps/dashboard/ee/ (licensed under Business Source License 1.1 — see apps/dashboard/ee/LICENSE for details; automatically converts to AGPL-3.0 on June 21, 2030) and is overlaid into the build by apps/dashboard/scripts/apply-ee.mjs: build:cloud copies it into app/, build:community strips it. pnpm dev builds the cloud edition so you get every feature locally; the SDK swap relies on webpack, so a Turbopack next dev --turbo would behave as cloud regardless.
Two kinds of runtime gating (within the cloud edition — see lib/tier.ts + lib/entitlements.ts):
- Capability-gated — integrations and managed email unlock on credentials present. A self-hoster who registers their own OAuth app / SMTP relay gets them; Cloud just pre-supplies the creds.
- Plan-gated (Cloud-only) — AI clustering/ticket drafts and Session Record require
CRUMB_TIER=cloudand a workspace plan that includes them. On Cloud, the workspace's Stripe subscription drives this:plan_id(free|team|growth) comes from the price'slookup_key; an active sub on Team unlocks AI + integrations, Growth adds Session Record. A BYO key does not unlock these on self-host — by design, they're the paid differentiators.
To run as Cloud yourself (e.g. for the hosted deployment):
CRUMB_TIER=cloud
RESEND_API_KEY=re_xxxxxxxxxxxx
CRUMB_EMAIL_FROM="Crumb <noreply@crumb.localhostlabs.net>"
CRUMB_STORAGE_PROVIDER=postgres # share attachment + replay bytes across instances via the DB
STRIPE_SECRET_KEY=sk_live_xxx # billing — plans drive feature entitlements
STRIPE_WEBHOOK_SECRET=whsec_xxx # prices: set lookup_key per price to
# team_monthly|team_annual|growth_monthly|growth_annual
AISTACK_API_KEY=xxx # AI features: clustering, ticket drafts, Ask (Team plan)
# AISTACK_BASE_URL / AISTACK_MODEL # optional — override the OpenAI-compatible endpoint / model
# Slack / Linear / Jira / GitHub OAuth-app credentials as added laterStorage: default local (per-instance disk) is fine for single-node self-host. A multi-instance Cloud deployment must set CRUMB_STORAGE_PROVIDER=postgres so attachments + Session Record chunks live in the DB and every instance sees them (an S3/R2 adapter is the eventual home for large blobs).
Magic-link emails (and the rest of the transactional emails) route through a pluggable provider. By default Crumb logs them to stdout — fine for dev, useless for a public deploy. Two real-provider options ship today.
SMTP — works against Postmark, SendGrid, SES, Mailgun, or self-hosted Postfix. Free on both tiers; bring your own relay credentials:
CRUMB_EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.postmarkapp.com
SMTP_PORT=587 # 465 for implicit TLS
# SMTP_SECURE=true # set when using 465
SMTP_USER=your-token
SMTP_PASS=your-token
CRUMB_EMAIL_FROM="Crumb <crumb@yourdomain.com>"Resend (Cloud-only) — managed; we hold the API key:
CRUMB_TIER=cloud
CRUMB_EMAIL_PROVIDER=resend
RESEND_API_KEY=re_xxxxxxxxxxxx
CRUMB_EMAIL_FROM="Crumb <crumb@yourdomain.com>"Any sender domain you use must have SPF/DKIM set up at the provider first or messages won't deliver.
When a vendor replies to a thread, the customer gets an email. Without inbound wiring, that email goes out from a noreply@… address. To let customers reply by email and land back on the thread:
CRUMB_INBOUND_DOMAIN=reply.yourdomain.com
CRUMB_INBOUND_SECRET=optional_shared_secret # protects the webhookThe dashboard exposes POST /api/v1/inbound/reply accepting a generic JSON shape ({ to, from, text, subject? }). Point your provider's inbound parser at that endpoint (Resend Inbound, SendGrid Inbound Parse, Postmark, or a Mailgun route all work). The Reply-To on every notification is reply+<shortId>.<token>@<CRUMB_INBOUND_DOMAIN>, signed with the workspace's signing secret.
Forward-to-capture. A second inbound flow turns forwarded mail into feedback: point a parser at POST /api/v1/inbound/email and any message sent (or forwarded) to your capture address lands as a pending capture — a triable item you confirm onto an account or discard, rather than a reply on an existing thread. Same CRUMB_INBOUND_DOMAIN / CRUMB_INBOUND_SECRET wiring.
Crumb Cloud monetizes via Stripe. The community (self-host) edition omits billing entirely: the /settings/billing page and the Stripe webhook aren't compiled in (they live in apps/dashboard/ee/), the Billing nav item is hidden, and the stripe SDK is excluded from the bundle. Build the cloud edition (CRUMB_EDITION=cloud) to get them.
To wire Stripe on a Cloud deployment:
CRUMB_TIER=cloud
STRIPE_SECRET_KEY=sk_live_… # or sk_test_… in test mode
STRIPE_WEBHOOK_SECRET=whsec_… # from "Add endpoint" in the Stripe dashboard
# Optional:
STRIPE_PORTAL_RETURN_URL=https://crumb-app.localhostlabs.net/settings/billingCreate one product with four prices and set each price's lookup_key to team_monthly, team_annual, growth_monthly, and growth_annual. Checkout resolves the right price by (plan, interval); the webhook maps the key's prefix (team/growth) onto plan_id. Enable Stripe Tax (automatic_tax) since Crumb is the merchant of record on the Stripe-direct path.
Then point a Stripe webhook endpoint at /api/v1/stripe/webhook and subscribe to:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
The handler keeps each workspace's plan_id, subscription_status, seats, current_period_end in sync with Stripe. New customers self-serve via /signup (Cloud-only; email-verified, creates a free workspace), then workspace admins pick a plan + interval on /settings/billing to land on a Stripe-hosted Checkout, and Manage subscription opens the Customer Portal for invoices, card updates, or cancellation. Identity/entitlement gates that depend on plan live behind isActiveStatus() in apps/dashboard/lib/stripe.ts.
Public-API POST endpoints (/api/v1/items, /api/v1/items/[shortId], /api/v1/uploads, /api/v1/inbound/reply) are rate-limited per source IP using an in-memory token bucket. Defaults:
CRUMB_RATE_LIMIT_CAPACITY=60 # tokens per bucket
CRUMB_RATE_LIMIT_REFILL_PER_SEC=1 # tokens per secondExhausted buckets return 429 rate_limited with a Retry-After header. The limiter is process-local — fine for single-VM self-host. Cloud multi-instance swaps in a Redis-backed implementation behind the same helper.
Slack, Linear, Jira, and GitHub all connect through a single central OAuth app per provider, read from env — there's no per-workspace app to register. So on Crumb Cloud (where we set those env vars once) connecting is genuinely one-click: a workspace admin opens Settings → Integrations, clicks Connect, approves on the provider, and lands back connected. Self-host is BYO — each operator registers their own provider apps and sets the same env vars (per-provider stanzas below). Connecting is gated to workspace admins, and on Cloud it requires the Team plan.
Redirect URL — one knob: every OAuth callback is {origin}/api/integrations/<provider>/callback.
By default the origin is inferred from the request (x-forwarded-host). Behind a proxy/load balancer
that's brittle, so set CRUMB_APP_URL to the dashboard's public origin (e.g.
https://app.yourdomain.com) — it becomes the canonical origin for all provider callbacks (precedence:
CRUMB_APP_URL → per-provider *_REDIRECT_URL → request host). Register that exact callback URL in
each provider's app. If a connect attempt can't proceed (provider not configured, plan required, not an
admin), the Connect button now shows the reason inline instead of silently doing nothing.
Workspace admins can connect Slack from Settings → Integrations to DM teammates when a customer replies — instead of (or in addition to, on a per-user basis) email. Each member picks their delivery channel under Notifications → Preferences; Slack falls back to email if the lookup or DM send fails, so customer replies never go silently dropped.
Cloud comes with a Slack app registered. Self-host needs to bring its own — set:
SLACK_CLIENT_ID=...
SLACK_CLIENT_SECRET=...Then register a Slack app at api.slack.com/apps with bot scopes chat:write, im:write, users:read, users:read.email and redirect URL {dashboard origin}/api/integrations/slack/callback. Optional: SLACK_REDIRECT_URL if the dashboard sits behind a proxy that mangles x-forwarded-host; SLACK_STATE_SECRET if you want a dedicated HMAC key for the OAuth state (otherwise falls back to CRUMB_INBOUND_SECRET, then SLACK_CLIENT_SECRET).
Slack user lookups are by email — each workspace member is matched once to their Slack user_id and cached on first DM. Failed lookups (member's Slack email doesn't match their Crumb email) are retried after 24h.
Microsoft Teams. Teams uses an incoming-webhook URL rather than OAuth: paste a channel's webhook under Settings → Integrations and Crumb posts the same close-the-loop events as Adaptive Cards. The Test button sends a sample card so you can confirm the wiring before going live.
Push Crumb items out as Linear / Jira / GitHub tickets from the thread sidebar. Status syncs back via webhook (one-way, provider is canonical for engineering status; Crumb is canonical for the vendor↔customer relationship). On Cloud with AISTACK_API_KEY set, the modal also offers Suggest with AI — drafts a title + body matched to your team's voice using up to 10 recent ticket titles from the target, plus (if a GitHub repo is connected on the workspace) the repo's README + top-level tree as project context for any provider's draft.
Cloud comes with all three apps registered. Self-host BYO. See apps/dashboard/.env.local.example for the full env stanzas; the short version:
- Linear: register an OAuth app at
linear.app/settings/api→LINEAR_CLIENT_ID/LINEAR_CLIENT_SECRET(+ optionalLINEAR_WEBHOOK_SECRET). Redirect URL:{dashboard origin}/api/integrations/linear/callback. - Jira: register a 3LO app at
developer.atlassian.comwith scopesread:jira-work write:jira-work read:jira-user offline_access→JIRA_CLIENT_ID/JIRA_CLIENT_SECRET(+JIRA_WEBHOOK_SECRET). Atlassian's per-tenantcloud_idis discovered automatically + re-checked on every token refresh so reinstalls against a different site don't silently 404. - GitHub: register a GitHub App (not an OAuth App) at
github.com/settings/appswith permissionsIssues:rw + Contents:r + Metadata:rand theIssuesevent →GITHUB_APP_ID,GITHUB_APP_SLUG,GITHUB_APP_PRIVATE_KEY(PEM), plusGITHUB_WEBHOOK_SECRET. Installation tokens are minted per-call from the App's JWT and cached in module memory for 50 minutes.
Webhook URLs (configure inside each provider's app settings):
{dashboard origin}/api/integrations/linear/webhook
{dashboard origin}/api/integrations/jira/webhook
{dashboard origin}/api/integrations/github/webhook
What this does not do: write Crumb status changes back to the provider, mirror comments/attachments, support multi-tracker links per item, or read deep repo code (README + tree only). See the phase plan for the deferred list.
Crumb prioritizes by the revenue behind a request, so every account carries an ARR value. Connect HubSpot or Salesforce under Settings → Integrations and Crumb pulls your companies/accounts and their ARR, then matches incoming feedback to the right account. Like the engineering integrations it's one-click on Cloud (Team plan) and BYO OAuth on self-host:
- HubSpot:
HUBSPOT_CLIENT_ID/HUBSPOT_CLIENT_SECRET(+HUBSPOT_ARR_PROPERTYto name the company property holding ARR). Redirect URL{dashboard origin}/api/integrations/hubspot/callback. - Salesforce:
SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET(+SALESFORCE_LOGIN_URLfor a sandbox / My Domain). Redirect URL{dashboard origin}/api/integrations/salesforce/callback.
Line up fields under Settings → Account mapping. Connect-time sync and the Sync now button work immediately; to keep ARR fresh, hit POST /api/v1/internal/crm-sync on a schedule (header X-Crumb-Sweep-Secret, e.g. every 6h). ARR you set by hand is marked manual and is never overwritten by a sync unless you opt in.
Once a workspace has at least one Initiative, Crumb Cloud can auto-suggest which Initiative new feedback belongs to. Vendors review the guess inline — one click accepts, one click dismisses. Cloud-only; self-host stays untouched.
CRUMB_TIER=cloud
AISTACK_API_KEY=xxxxxxxxxxxxxxxxxxxxBehaviour:
- On every new item:
POST /api/v1/itemsfires a background classification against the workspace's non-parked initiatives. If the model picks one with confidence > 0.55, a pending suggestion is stored. The customer never waits — the API responds before the LLM call. - Inbox: items with a pending suggestion show an
✨ User Management ✓ ✗chip in the Initiative column. Hover shows the model's one-line reason + confidence. - Thread sidebar: under the Initiative card, an "AI suggests" panel appears when nothing's set yet — same accept/dismiss controls.
- Bulk: select unclassified items in the inbox and click Cluster selected to batch-classify (capped at 25 per click to keep costs bounded).
Runs on aistack — an OpenAI-compatible endpoint serving an open-weight model (qwen-35b-8bit by default); point AISTACK_BASE_URL / AISTACK_MODEL at your own inference host to use a different one. Accepted/dismissed history is kept so a later model rerun can supersede an earlier dismissal.
Ask a plain-English question across your feedback and get an answer grounded in the actual items — not a guess. The question is embedded, matched against the feedback corpus by vector similarity (pgvector), and an answer is composed from the top matches with inline [FB-N] citations that resolve back to the source items. Questions about product usage route to your usage data instead. Cloud-only (Team plan); needs AISTACK_API_KEY and the pgvector-enabled Postgres image (pgvector/pgvector:pg16).
Settings → Insights turns the raw stream into a read on the relationship: median loop time (submission → outcome), open loops and the ARR behind them, first-response time, volume by status, a 12-week trend, and volume by account ARR tier. Stream product usage in via POST /api/v1/usage-events (crumb.track() from the widget) and accounts that go quiet or trend negative surface as at-risk churn signals. Export a per-account, QBR-style CSV from GET /api/v1/insights/export. The non-AI metrics run on self-host; the AI-enriched churn/sentiment signals need a Cloud Team plan.
When a customer drops feedback through the widget, Crumb Cloud can attach a video-like replay of the last few minutes of their session — so the vendor sees what they were actually doing, not just what they wrote. Built on rrweb (MIT). Cloud-only.
Enable per-workspace at Settings → Integrations → Session record. When on, the widget injects a second small bundle (/widget-record.js) after /me resolves; the launcher itself stays lean. Captured chunks flush every ~5s via fetch, with a sendBeacon final-flush on pagehide.
Privacy defaults (locked-in, not configurable in v1):
- All
<input>values are masked. password/emailinput types are hard-blocked.- The Crumb widget itself is blocked from recording (no recursive UI).
- Vendors can opt out customer-side via
class="crumb-block"(skip whole subtree) orclass="crumb-mask"(mask text).
Cost guard-rails (capped per session):
- 10 MB of events
- 5,000 events
- 30 minutes
The recorder stops itself client-side at each cap; the server returns 413 if exceeded. Sessions are linked to a feedback item only after the customer submits with ≥1 chunk flushed — empty sessions stay orphan and can be swept later. Per-plan retention is enforced by the sweep — set CRUMB_REPLAY_RETENTION_DAYS (with _FREE / _TEAM / _GROWTH overrides) and aged sessions past the window are pruned; with no retention configured it's a no-op.
CSP gotcha: if the customer's site uses script-src 'self', the recorder script tag won't load. Allow your Crumb origin in script-src to enable session record on that site.
Pruning orphan sessions: sessions where the customer never submitted feedback sit around indefinitely without a sweep. Set CRUMB_INTERNAL_SWEEP_SECRET=<random> and POST to /api/v1/internal/replay-sweep with header X-Crumb-Sweep-Secret: <random> from your cron. Defaults: prune item_id IS NULL sessions older than 24h, 500 rows per call. Response includes { deletedSessions, deletedChunks, releasedBytes }.
Open http://localhost:3000/widget-demo.html — a mock analytics dashboard with Crumb's launcher in the bottom-right.
Customer side (in the widget):
- Click the launcher → drop a crumb (bug / idea / question + title + details).
- The confirm screen has a "See your feedback" button — opens the customer's inbox.
- Click any item to read the thread, see vendor replies, and reply back inline.
Vendor side (in the dashboard): 4. Reload /inbox — the new item is at the top. Click in. 5. Type a reply or move it through the status flow. 6. Reload the widget — the vendor reply is now visible to the customer.
To embed it in your own product:
<script src="https://your-crumb-host/widget.js"
data-workspace="southbeam"
data-user-email="user@theircompany.com"
data-user-name="User Name"
data-account-name="Their Company"
defer></script>(For trusted identity, pass a signed data-user-jwt instead of the plain data-* attributes; the API then trusts only the JWT's claims. On Cloud the signed JWT is required — the API rejects plain data-* identity with 401 jwt_required; self-host still accepts plain attributes as a fallback.)
| Method | Path | What it does |
|---|---|---|
POST |
/api/v1/items |
Create an item; auto-creates account + user |
GET |
/api/v1/items?workspace&email |
List the calling customer's submissions |
GET |
/api/v1/items/[shortId]?workspace&email |
Read a thread (only if the caller submitted it) |
POST |
/api/v1/items/[shortId] |
Customer reply on a thread |
GET |
/api/v1/roadmap?workspace&email |
Public roadmap items, grouped Now / Next / Later |
GET |
/api/v1/me?workspace&email |
The calling customer's identity + workspace meta |
POST |
/api/v1/uploads |
Upload an attachment |
All endpoints CORS-enabled (Access-Control-Allow-Origin: *) until auth narrows it down per-workspace.
The embedded widget will POST to /api/v1/items. You can hit it manually right now:
curl -X POST http://localhost:3000/api/v1/items \
-H "Content-Type: application/json" \
-d '{
"workspace_slug": "southbeam",
"account_user_email": "you@yourco.com",
"account_user_name": "You",
"account_name": "Your Co",
"type": "idea",
"title": "A short title",
"body": "Optional longer body"
}'Reload /inbox and the item is there. Click in to reply or move it through the status flow.
| Script | What it does |
|---|---|
pnpm dev |
Next.js dev server |
pnpm build |
Next.js production build |
pnpm db:up |
Start the Postgres container |
pnpm db:down |
Stop the Postgres container (data persists) |
pnpm db:push |
Sync schema → DB (Drizzle Kit, dev only) |
pnpm db:seed |
Reseed sample data (wipes existing rows first) |
pnpm db:studio |
Open Drizzle Studio against the local DB |
pnpm widget:build |
Build the embed widget bundle |
pnpm widget:dev |
Rebuild the widget on save (esbuild watch) |
The whole stack — Postgres + the dashboard + the widget bundle it serves — runs from one compose file. From a fresh checkout:
docker compose up -d --buildThis builds the dashboard image, brings Postgres up, waits for it to be healthy, then starts the dashboard. The container runs SQL migrations on each startup before booting the server. Visit http://localhost:3000.
Override anything via env or a .env file at the repo root:
| Var | Default | What it does |
|---|---|---|
POSTGRES_USER |
crumb |
Postgres role |
POSTGRES_PASSWORD |
crumb |
Postgres password |
POSTGRES_DB |
crumb |
Postgres database name |
POSTGRES_PORT |
5432 |
Host port for Postgres |
DASHBOARD_PORT |
3000 |
Host port for the dashboard |
CRUMB_WORKSPACE_SLUG |
southbeam |
Workspace the dashboard renders (until auth lands) |
CRUMB_SKIP_MIGRATIONS |
(unset) | Set to 1 to skip the migrate step on container startup |
CRUMB_EDITION |
community |
Build arg (not runtime): community self-host build or cloud. See Self-host vs. Crumb Cloud |
CRUMB_EDITION is consumed at --build time (passed through to the Dockerfile by docker-compose.yml), so changing it requires a rebuild: CRUMB_EDITION=cloud docker compose up -d --build. The default community build omits the Stripe-billing and session-replay routes and their SDKs.
To seed sample data into a fresh self-hosted deploy:
docker compose exec dashboard sh -c "node packages/db/dist/migrate.mjs && true" # already ran via entrypoint
# Then from your host (need pnpm available):
DATABASE_URL=postgres://crumb:crumb@localhost:5432/crumb pnpm db:seedFor a real deployment, point DATABASE_URL at your own managed Postgres and skip the postgres service.
The hosted tier is live at crumb-app.localhostlabs.net, running the same code with auth, billing, and the AI features layered on top.
Two probes for load balancers / orchestrators:
GET /api/health— liveness: always200 {ok:true}while the process is up. Restart the container if this fails.GET /api/health/ready— readiness:200only when Postgres is reachable, else503. Route traffic only to ready instances.
The bundled docker-compose.yml already wires the dashboard container healthcheck to /api/health/ready.
Every sensitive env var also accepts a *_FILE companion pointing at a file whose contents are the value — the Docker/Kubernetes secrets convention. The entrypoint loads FOO from FOO_FILE at startup (for both the migrate step and the server) unless FOO is already set directly. Example with Docker secrets:
services:
dashboard:
environment:
DATABASE_URL_FILE: /run/secrets/db_url
CRUMB_ENCRYPTION_KEY_FILE: /run/secrets/enc_key
STRIPE_SECRET_KEY_FILE: /run/secrets/stripe_key
secrets: [db_url, enc_key, stripe_key]
secrets:
db_url: { file: ./secrets/db_url }
enc_key: { file: ./secrets/enc_key }
stripe_key:{ file: ./secrets/stripe_key }For production, set CRUMB_ENCRYPTION_KEY (openssl rand -hex 32) so integration tokens are encrypted at rest — see apps/dashboard/.env.local.example, which documents every variable.
Crumb keeps everything in Postgres (with CRUMB_STORAGE_PROVIDER=postgres, that includes attachment + replay bytes), so a single pg_dump is a full backup.
# Back up (compressed custom format)
docker compose exec -T postgres pg_dump -U crumb -Fc crumb > crumb-$(date +%F).dump
# Restore into a fresh DB
docker compose exec -T postgres pg_restore -U crumb -d crumb --clean --if-exists < crumb-2026-01-01.dumpSchedule the dump however you like (host cron, a sidecar) and ship the file off-box. Migrations are forward-only and run on startup; restoring a dump from an older release, then deploying the newer image, applies any pending migrations automatically.
To prune orphaned uploads + replay sessions (and enforce replay retention), set CRUMB_INTERNAL_SWEEP_SECRET and hit the cleanup endpoint on a schedule:
curl -X POST -H "X-Crumb-Sweep-Secret: $CRUMB_INTERNAL_SWEEP_SECRET" \
http://localhost:3000/api/v1/internal/replay-sweepAGPL-3.0. If you offer Crumb as a service to others, the source of your fork has to stay open. Self-hosting for your own company is fully permitted.


