Skip to content

kanboard — task board for EmbeddedEra (tasks.embeddedera.com)

Two services at the end of the torrentvpn compose file: kanboard (kanboard/kanboard:v1.2.46, SQLite) and kanboard-proxy (caddy, header translation only — no TLS, Cloudflare Tunnel does that). Phase 1 of task-board. Same identity pattern as inventree: a reverse-proxy header, never a local password.

Why this shape

Free, single-container, no database service to run alongside it (SQLite is enough at this scale), JSON-RPC API for scripting, and REVERSE_PROXY_AUTH support that lets Cloudflare Access carry identity the same way it already does for the ERP. Chosen over a heavier board (Taiga, Focalboard) because the whole job here is "one list of tasks per department" — Kanboard's project/board model maps onto that directly, with onboard kanboard-sync doing the provisioning instead of anyone clicking through its admin screens.

Shape

  • Volumes: ./kanboard/config.php (ro), ./kanboard/data (SQLite db.sqlite, owned by the container's uid — dhcpcd:messagebus on the host, not angel; erp-backup cannot open it read-write for sqlite3 .backup and falls back to a plain cp, safe because the file is never left mid-WAL between requests), ./kanboard/plugins (empty, reserved).
  • No published port on kanboard itself. kanboard-proxy publishes 127.0.0.1:8400 only — loopback, same as InvenTree's 127.0.0.1:8100. Nothing else in the stack reaches Kanboard directly except over the compose network by container name (http://kanboard:80), which is how the Uptime Kuma monitor checks it.
  • Identity — read this before touching the proxy or config.php. config.php sets REVERSE_PROXY_AUTH on, reading the username from the X-Remote-User header. kanboard/Caddyfile is a two-rule chain, order matters: (1) request_header -X-Remote-User strips any inbound copy of that header before anything else runs, (2) it is set again, only inside the @authenticated block, only from Cf-Access-Authenticated-User-Email — the field Cloudflare itself fills in after its Access policy passes, which cannot be forged by a client. The trust boundary is the loopback bind, exactly as for the ERP: whoever can reach 127.0.0.1:8400 on this host is authenticated as whatever X-Remote-User they send, with no further check. Nothing outside this host can reach that port; nothing on this host runs untrusted code that talks to it. HIDE_LOGIN_FORM + DISABLE_LOGOUT + LDAP_AUTH=false keep Kanboard's own login dead so there's no side door around Access.
  • Name: tasks.embeddedera.com — Cloudflare Tunnel ingress → kanboard-proxy:80, gated by the Access app "EmbeddedEra Tasks" (token tasks in ~/Notes/roles/departments.yml, held by every working department's function bundle). DNS, tunnel ingress and the Access app already existed before this card was written; forged-header tests pass (proxy drops/replaces X-Remote-User before it reaches the container regardless of what the client sent).
  • API: JSON-RPC 2.0, single endpoint http://127.0.0.1:8400/jsonrpc.php, HTTP basic auth (jsonrpc / the API token). Token is Kanboard's own settings.api_token row; stored 0600 at ~/.config/kanboard/env (KANBOARD_URL, KANBOARD_API_TOKEN). Client wrapper: ~/.local/bin/_kanboard.py, shared by onboard kanboard-sync, tasks-migrate, and tasks-export.
  • Provisioning is never manual. onboard kanboard-sync (same plan/--yes shape as access-sync) creates one Kanboard project + one Kanboard group per department that holds the tasks token (Administration, Engineering, Finance, Production, Purchasing, Sales), creates or updates a Kanboard user per person holding that token (username = email, name, email set, notifications_filter = assigned-to-me-only), puts them in their department's project + group, and deactivates anyone who no longer holds the token. Never hand-manage a Kanboard user.
  • Task migration: ~/.local/bin/tasks-migrate copies the assistant's old sqlite tasks table (141 rows) into Kanboard once, idempotently — each row's Kanboard reference field is assistant-sqlite:<row id>, checked with getTaskByReference before creating, so re-running never duplicates. source/source_ref are written to Kanboard task metadata (saveTaskMetadata) for the SOP-audit dedupe hook. The sqlite table is never written by this script or by tasks-export --sqlite — it stays the documented, cheap exit path if Kanboard is ever abandoned.
  • tasks-export (nightly, writes ~/Notes/tasks.md) now reads Kanboard by default; --sqlite switches it back to the old table for the same reason.

Connected to

  • owned-by: torrentvpn-stack
  • joins: Cloudflare Access (identity) · ~/.config/assistant/people.yml + ~/Notes/roles/departments.yml (who gets a project/group, via onboard kanboard-sync) · ~/.local/state/assistant/assistant.sqlite tasks table (one-time migration source, read-only) · Uptime Kuma (monitor "Kanboard", http://kanboard:80 on the compose network) · erp-backup (nightly sqlite + attachment backup)

If you change this

  • Hits: onboard kanboard-sync (project/group names are literal department titles — renaming a department in departments.yml orphans the old Kanboard project until someone renames it to match) · tasks-export's Kanboard read path · the Cloudflare Access app "EmbeddedEra Tasks".
  • Restart: docker compose up -d kanboard kanboard-proxy (named — never bare).
  • Restore: stop the container, copy kanboard/data/db.sqlite (and data/files/ if any attachments exist) back from an erp-backup restore <stamp> set, start. The API token in ~/.config/kanboard/env survives a restore only if the restored db still has that row — check settings.api_token after a restore and regenerate if it changed.

Mail — three bugs deep (2026-09-14)

Assignment emails silently did nothing. Three separate faults, each of which alone would have been enough, and each of which looked like a credential problem:

  1. No notification channel selected. notifications_enabled = 1 is only half of it — Kanboard also needs a row in user_has_notification_types saying email or web. With none, notifications are "on" and nothing is ever sent. onboard kanboard-sync now writes that row for every user it provisions.
  2. The SMTP transport was built before its encryption was set. Upstream's Core/Mail/Transport/Smtp.php calls newInstance($host, $port) and then setEncryption(), which is too late. Patched copy mounted from kanboard/patch/Smtp.phpre-check it after any Kanboard upgrade.
  3. PHP-FPM clears the environment. config.php reads the Resend key with getenv(), which works from the command line and returns an empty string in a web request unless the variable is listed in the FPM pool. So the key tested fine by hand and was empty on every real notification. Fixed by mounting kanboard/patch/env.conf with env[RESEND_API_KEY].

All three reported the same misleading error: "Failed to authenticate on SMTP server with username resend using 2 possible authenticators" — which sends you hunting for a bad key. The key was valid the whole time; it was empty, then unencrypted, then unrouted.

How to prove mail works rather than assume: reassign a task to someone and check docker logs kanboard for authentication errors. Silence is success.

Network — private, and why (2026-09-14, from the audit)

kanboard and kanboard-proxy sit on kanboard-net, a private bridge with nothing else on it. Before this they were on the shared default bridge with forty other containers, and because Kanboard trusts an identity header, any of them could set X-Remote-User and be Kanboard admin — Grafana was the one tested, and it worked. The Caddyfile's header discipline only protects traffic that goes through Caddy; membership of the network is what stops traffic going round it.

Consequences: Uptime Kuma can no longer probe kanboard:80, so the monitor is a push fed by assistant-heartbeat through the loopback proxy (302 to the login page is the healthy answer). And do not set the network internal: true — that also disables port publishing and took the board down for a few minutes when tried.

data/db.sqlite is mode 640, owned by the container's user, so another local account cannot read the register straight off disk. Backups still work because erp-backup takes its copy with VACUUM INTO from inside the container. The cp fallback in that script will not work any more, which is the right trade.

Theme (2026-09-14)

Brand colours and fonts via Kanboard's own Settings → Application → Custom Stylesheet. Done by setting Kanboard's CSS variables (auto.min.css declares 97 on :root--body-background-color, --table-list-nth-background-color, --dropdown-background-color and so on), not by overriding selectors: a first attempt with !important rules left the list views white with black dropdown boxes. Only the default task colour (yellow) is remapped; a task someone colours on purpose keeps its colour. (application_stylesheet in the settings table). Source of truth is ~/torrentvpn/kanboard/brand.css, tokens from ~/Notes/brand/README.md; it is pushed into the setting by hand (docker exec kanboard php -r …, see the session of 2026-09-14). CSS only — nothing about behaviour changes, and a Kanboard upgrade cannot break it, only ignore it. Re-apply the file if the setting is ever blanked.

Close-out (2026-09-14, SOP-017 v1.1)

Every project has five columns: Backlog, Ready, Work in progress, Review, Done — reconciled by onboard kanboard-sync (kanboard_sync_board_shape), together with one automatic action per project, TaskCloseColumn on Done (the action name must be fully qualified, \\Kanboard\\Action\\TaskCloseColumn, and column_id a string, or createAction silently fails). People move a card to Review; only ~/.local/bin/task-verify (user timer, every five minutes) moves it to Done, which closes it. It reads the task's Verify: block, runs machine checks (file:, attachments>=N, notes-contains:, url-contains:, erp: … count>=N, audit-check-passes:), sends the rest to Haiku as a checklist, and comments Verified: / Missing: / Needs a human check:. A human verifier is someone in the department other than the assignee, else Administration; they answer with a comment starting Verified or Missing:. Pending human checks: ~/.local/state/task-verify/pending.json, shown in the notebook's front page under Waiting on you (moved off the home page 2026-09-14). Every decision is a row in reports.task_verifications. A task closed by hand in the last three days without a Verified: comment is reopened into Review. Comments via the API are by user 1 (jsonrpc); createComment accepts user_id to speak as someone else — only the intake page and tests should. Audit check SOP-017.closed-unverified reads the same rule over the API (the sqlite file belongs to the container and is not host-readable).

Close out a task from a phone (2026-09-14): the intake page (~/assistant/intake.py, static/intake.html) has "Close out a task" as its first option: GET /intake/api/mytasks lists the caller's own open tasks in Ready / Work in progress / Review; POST /intake/api/closeout (task_id, done ≥10 chars, optional file marked source or proof) attaches the file to the task, copies a source to precious/EmbeddedEra/records/task-<id>/ (any file type; a proof must be a photo or PDF), posts the Done: comment as the caller, moves the card to Review, runs task-verify --task N --yes synchronously and returns the resulting Verified: / Missing: / Needs a human check: comment so the page answers on the spot. Ownership is enforced: you can only close out a task assigned to you.

Raising from a phone (2026-09-14): the intake page also has New task (POST /intake/api/newtask: one Haiku call scrubs the person's words into SOP-017 form — title that says what done looks like, concept, Deliverables + Verify, open questions; filed in the chosen department's project, owner = filer, due in 30 days; if the scrub fails the raw text is filed with placeholders so nothing is lost) and Idea (POST /intake/api/idea: project Ideas (id 8), owner = filer, no due date, no deliverables — not a task until it is moved to a department and given Deliverables + Verify). Ideas is excluded from the SOP-017 open-task audit checks. ~/.local/bin/tasks-deliverables backfills Deliverables/Verify on any open task lacking them (Haiku, ~$0.003 each; 121 tasks on 2026-09-14 for $0.37); a malformed machine line is demoted to "Check by hand: …".

Per-event email is OFF (2026-09-14). A bulk edit (86 assignments + 122 due dates) made Kanboard send ~200 messages and Resend's free tier (100/day) stopped processing. notifications_enabled = 0 for every account, kept so by onboard kanboard-sync (written straight into the users table; updateUser over JSON-RPC does not change the flag). People hear about tasks from tasks-notify (hourly 07–21, one batched message per person, only when something happened) and tasks-digest (07:30). Every sender on the box draws on ~/.local/bin/_mailbudget.py (60/day, state in ~/.local/state/mail-budget/<day>.json); BudgetExceeded drops the batch and logs it. The mail-bugs section above still applies if per-event mail is ever wanted back.

Stock count (2026-09-14, SOP-023): intake kind stock — reel photo → zxing-cpp decodes the DigiKey/Mouser Data Matrix (MH10.8.2; zxing renders GS/RS as U+241D/U+241E, handled) or the LCSC QR, OCR + Haiku fill the rest, one Production task per item (Stock: <mpn> × <qty>, stock_json metadata, Verify erp: stock/?…search=<mpn>). /stock (assistant, production + admin) lists them, matches the MPN to ERP parts and supplier parts, and Add to ERP posts the stock item with the PRODUCTION token, writes stock_item_id, comments Done as the caller and moves the task to Review. Proven on the sandbox ERP (stock item 5, part EE-C-0001, Bin 03) before merge. Audit: SOP-023.stock-unmatched-stale (captures older than 14 days not confirmed or skipped).

Stock queue, one press (2026-09-14 evening): a row with no ERP part shows a proposed part (next IPN by SOP-001 class, name = MPN, distributor-style description and category from part-lookup, manufacturer, supplier, datasheet link) and Create part + add stock (POST /stock/api/create-and-confirm/{task}: part with the engineering token, reel photo as the part image, manufacturer + supplier parts, then the normal stock confirm). Every location picker has New bin… (get-or-create under a parent; GET /stock/api/next-bin suggests "Bin nn"). Labels print straight to the Zebra (zebra.py: ERP PDF → pdftoppm 300 dpi → ^GFA ZPL → TCP 9100 at ~/.config/zebra/env; GET /stock/api/printer greys the buttons when it is off; PDF links are the fallback). Proven on the sandbox ERP (part EE-C-0147, a new bin, image, supplier part, stock item, all deleted after) and one real label printed to the GX430t from production code.