Self-hosted, agent-native kanban board + markdown document workspace. Cards
carry full-markdown descriptions, standalone documents live alongside boards,
and AI agents are first-class principals — same API, same attribution, own
identity. See CLAUDE.md for the architecture contract.
npm install
npm run dev # dev server
npm run build # production build → build/
node --env-file=.env build/index.js # run itThe app applies pending migrations from drizzle/ on boot, dev and
production alike; npm run db:migrate applies them without starting the app.
Configuration lives in .env (see .env.example). Sign-in is OIDC (any
provider, e.g. Authentik) and/or local username/password — on a fresh instance the first
registered user becomes the superadmin, after which local registration is
closed until an admin opens it (PATCH /api/v1/admin/settings).
API reference: /api/docs (interactive), /api/v1/openapi.json (spec),
/llms.txt (agent-oriented overview).
Two env vars matter when hashboard sits behind a proxy (NPMplus, nginx, …):
ORIGIN— the public origin (https://hashboard.example.com). adapter-node cannot infer it, and the OIDCredirect_uriis built from it.ADDRESS_HEADER=X-Forwarded-For(plusXFF_DEPTH=1for a single trusted proxy) — without it, adapter-node sees every request as coming from the proxy's IP. The login/register rate limiter keys on client IP, so leaving this unset behind a proxy collapses it into one shared bucket: ~10 register attempts per 15 minutes for all visitors combined, and one abusive client can lock out everyone. Set it in production, always.
TLS is mandatory in production. The production build sets Secure on the
session cookie, so over plain HTTP on any non-localhost address the browser
accepts the cookie and then never sends it back — sign-in appears to do
nothing, with no error in the UI or the server log. If sign-in "does nothing"
on a LAN address, this is why: put TLS in front or test on localhost.
ecosystem.config.cjs runs the production build under pm2
— restart on crash, start on boot:
npm ci && npm run build
pm2 start ecosystem.config.cjs
pm2 save # persist across reboots (once: pm2 startup)Never run it in pm2 cluster mode: SQLite has one writer and the app is
designed as a single process. /api/v1/health is unauthenticated by design
and is the healthcheck target.
A deploy is build/ plus package.json, package-lock.json, drizzle/,
scripts/migrate.mjs and an npm ci --omit=dev on the target — migration
uses drizzle-orm's runtime migrator (at boot, and in npm run db:migrate),
so production installs apply their own migrations without dev dependencies.
The alternative to pm2 — same single-process app, database on a named volume, migrations applied on boot:
docker compose up -d --buildUncomment and fill ORIGIN (and the reverse-proxy vars above) in
compose.yaml first. The port is published on localhost only, because TLS
and the public hostname belong to the reverse proxy in front; never scale
the service past one replica — same single-writer rule as pm2.
The backup guidance below applies unchanged; from a live container:
docker compose exec hashboard node -e "new (require('better-sqlite3'))(process.env.DATABASE_URL).exec(\"VACUUM INTO '/data/backup.db'\")"A backup is two things: the database and the attachment files. Attachment
bytes are stored on disk under ATTACHMENTS_DIR, not in the database, so a
snapshot of one without the other restores an instance whose download links all
fail. The Docker image puts both under /data precisely so that backing up one
directory is complete.
The database is a single SQLite file in WAL mode, which means a plain copy of
a live database is not safe — committed pages can still sit in the -wal
sidecar, so the copy may be stale or torn. Two safe forms:
# live, no downtime, one consistent file out
sqlite3 hashboard.db "VACUUM INTO '/backups/hashboard-$(date +%F).db'"
# or stop the process and copy all three files together
cp hashboard.db hashboard.db-wal hashboard.db-shm /backups/Then the files, which are immutable once written — a plain recursive copy is safe at any time, and only ever gains entries:
rsync -a data/attachments/ /backups/attachments/Take the database snapshot first. In that order the worst case is a file with no row pointing at it, which is invisible; the reverse leaves rows promising bytes the backup does not contain.