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(SQLitedb.sqlite, owned by the container's uid —dhcpcd:messagebuson the host, notangel;erp-backupcannot open it read-write forsqlite3 .backupand falls back to a plaincp, safe because the file is never left mid-WAL between requests),./kanboard/plugins(empty, reserved). - No published port on
kanboarditself.kanboard-proxypublishes127.0.0.1:8400only — loopback, same as InvenTree's127.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.phpsetsREVERSE_PROXY_AUTHon, reading the username from theX-Remote-Userheader.kanboard/Caddyfileis a two-rule chain, order matters: (1)request_header -X-Remote-Userstrips any inbound copy of that header before anything else runs, (2) it is set again, only inside the@authenticatedblock, only fromCf-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 reach127.0.0.1:8400on this host is authenticated as whateverX-Remote-Userthey 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=falsekeep 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" (tokentasksin~/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/replacesX-Remote-Userbefore 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 ownsettings.api_tokenrow; stored 0600 at~/.config/kanboard/env(KANBOARD_URL,KANBOARD_API_TOKEN). Client wrapper:~/.local/bin/_kanboard.py, shared byonboard kanboard-sync,tasks-migrate, andtasks-export. - Provisioning is never manual.
onboard kanboard-sync(same plan/--yesshape asaccess-sync) creates one Kanboard project + one Kanboard group per department that holds thetaskstoken (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-migratecopies the assistant's old sqlitetaskstable (141 rows) into Kanboard once, idempotently — each row's Kanboardreferencefield isassistant-sqlite:<row id>, checked withgetTaskByReferencebefore creating, so re-running never duplicates.source/source_refare written to Kanboard task metadata (saveTaskMetadata) for the SOP-audit dedupe hook. The sqlite table is never written by this script or bytasks-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;--sqliteswitches 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, viaonboard kanboard-sync) ·~/.local/state/assistant/assistant.sqlitetaskstable (one-time migration source, read-only) · Uptime Kuma (monitor "Kanboard",http://kanboard:80on 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 indepartments.ymlorphans 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(anddata/files/if any attachments exist) back from anerp-backup restore <stamp>set, start. The API token in~/.config/kanboard/envsurvives a restore only if the restored db still has that row — checksettings.api_tokenafter 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:
- No notification channel selected.
notifications_enabled = 1is only half of it — Kanboard also needs a row inuser_has_notification_typessaying email or web. With none, notifications are "on" and nothing is ever sent.onboard kanboard-syncnow writes that row for every user it provisions. - The SMTP transport was built before its encryption was set. Upstream's
Core/Mail/Transport/Smtp.phpcallsnewInstance($host, $port)and thensetEncryption(), which is too late. Patched copy mounted fromkanboard/patch/Smtp.php— re-check it after any Kanboard upgrade. - PHP-FPM clears the environment.
config.phpreads the Resend key withgetenv(), 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 mountingkanboard/patch/env.confwithenv[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.