Multi-user kanban with branching workflows, reusable templates, labels, custom fields, and a workflow-aware drag-and-drop UI. Phoenix LiveView on top of PostgreSQL.
The point of Waxx is that the board's columns aren't just buckets: they're a directed graph. You draw the graph once on a template (stages = nodes, transitions = edges), spin up boards from it, and cards can only move along edges the workflow allows. Both the UI and the server enforce it.
- Branching workflows — visual graph editor, server-side transition enforcement, click-to-connect stage editor, backward edges loop out to the right of the column.
- Reusable workflow templates — stages, transitions, labels and custom fields live on a template. Boards clone the template at creation; later template edits propagate by stage/label/field name to boards that haven't drifted.
- Custom fields — text / date / datetime / select, with a per-field "show on card" toggle that surfaces the value as a chip on the kanban tile.
- Labels — coloured tag chips; click-to-toggle in the card modal. Tailwind colour swatches + native picker for choosing colours.
- Drag and drop — HTML5 DnD with green / red drop highlights driven
by the workflow graph. Reorder within a column or drop into another;
invalid drops can't be released (
dragoverneverpreventDefaults on a forbidden target). - Live multiplayer — Phoenix PubSub broadcasts every card mutation and every template-driven workflow change to subscribed boards.
- Auto-archive — boards have an
archive_terminal_after_dayssetting (default 7). Cards in a terminal stage (no outgoing edges) longer than the threshold drop out of the kanban view automatically. Set to blank to never archive. Per-stage entry timestamp on each card is the source of truth — title/description edits don't reset it. - Board history — every card mutation (create / move / update /
delete / assign / label / field) is logged with actor, timestamp,
and a denormalised meta blob so the log keeps reading cleanly even
after referenced rows are renamed or deleted. Visit
/boards/:id/history. - Label-text toggle — per-user, per-board preference to render label chips as just coloured dots instead of name + colour. Stored on the user as a jsonb pref, falls back to a user-wide default. Toggle button lives next to History/Settings on each board.
- Tabbed template editor —
/workflow-templates/:id?tab=workflow|labels|fieldssplits the editor into three URL-addressable tabs with counts in the tab labels. - Accounts — magic-link login by default, password optional, sudo mode for sensitive changes, session tokens with reissue.
- Two invite systems — app-level (
/users/invites+mix phx.gen.invite) gates registration; board-level (/boards/:id/settings) grants membership with a role (owner / editor / viewer). - Subboards (2-D grid) — split a board into rows. The kanban view becomes a grid: subboards down the side, stages across the top. Cards start in the default row and drag/drop into a subboard cell; the server moves stage and subboard atomically per drop. Configure from the board's settings page.
- Themes — daisyUI with six ported from neiam-co plus a blueprint palette. Pick from the dropdown; choice persists to localStorage.
- UUIDv7 primary keys — time-ordered, so
ORDER BY idis almostORDER BY inserted_atfor free and the b-tree stays warm at the end.
# 1. Bring up Postgres (compose maps 54328 → 5432 inside)
podman-compose up -d # or docker compose up -d
# 2. Install deps, create + migrate the dev DB, build assets
mix setup
# 3. Start the dev server
mix phx.serverThen open http://localhost:4000.
Registration is invite-only by default. Mint a registration link from the CLI:
mix phx.gen.invite you@example.comThe task prints a URL — visit it to create your account.
:accounts_enabled(config/config.exs) — master switch. Whenfalsethe/users/*and/boards/*routes are not mounted and the auth nav vanishes. Useful for read-only / pre-launch deployments.:registration_open— whenfalse,/users/registeronly accepts visitors who carry a valid invite token. Existing users mint invite links from the UI or the mix task.
lib/
waxx/
accounts/ # user, user_token, invite (app-level), notifier
workflows/ # template, stage, transition, label, field
kanban/ # board, board_stage, board_transition, board_label,
# board_field, board_activity, membership,
# board_invite, card, card_assignee, card_label,
# card_field_value
accounts.ex # account context (registration, login, invites, prefs)
workflows.ex # template CRUD + propagation calls into kanban
kanban.ex # boards, cards, transitions, broadcasts, history log
uuidv7.ex # Ecto type
schema.ex # use-macro: UUIDv7 PK + FK defaults
themes.ex # theme catalog
waxx_web/
live/
template_live/ # /workflow-templates and the visual editor (tabbed)
board_live/ # /boards, /boards/:id, settings, history, invites
user_live/ # login, registration, settings, app-invites
components/
core_components.ex # buttons, inputs, the colour picker
layouts.ex # navbar + flash group + theme toggle
controllers/
board_invite_controller.ex # /b/:token redemption
user_session_controller.ex # login/logout/update-password
priv/repo/migrations/
test/
- Schemas all
use Waxx.Schema, which sets UUIDv7 PKs and FKs. - Migrations use
migration_primary_key: [type: :binary_id]fromconfig/config.exs; columns are Postgresuuid. - Template → board propagation matches by name. Renaming a stage on a board makes it "drift" — subsequent template edits touching that name skip the drifted board. Deletes only propagate when the matching board entity isn't in use (no cards in the stage, no card has the label, no value for the field). Drift over data loss.
- Transition enforcement lives in
Kanban.move_card/3: same-stage calls fall through toreorder_card/2; cross-stage requires aBoardTransitionfrom current → target. Server returns{:error, :invalid_transition}if you bypass the UI. - PubSub topic is
"board:#{id}".Kanban.subscribe/1joins; the kanban LiveView re-fetches cards on:cards_changedand the full workflow snapshot on:workflow_changed. - Activity log writes are best-effort, post-mutation. The mutation succeeds or fails on its own merits; logging never blocks the business operation. Meta is stored as denormalised strings so the history view stays readable after referenced rows change.
- User preferences live in a single
users.preferences :map(jsonb) column. Keys follow"<feature>:<scope_id>"for per-scope overrides and"<feature>_default"for user-wide defaults — seeAccounts.hide_label_text?/2for the lookup pattern.
mix precommit # compile --warnings-as-errors, deps.unlock --unused, format, test
mix test # 35 tests, mostly context-level
mix phx.gen.invite EMAIL # mint an account-registration inviteIf you change the schema and want to start from a clean dev DB:
PGPASSWORD=postgres psql -h localhost -p 54328 -U postgres -d postgres \
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \
WHERE datname IN ('waxx_dev','waxx_test') AND pid <> pg_backend_pid();"
mix ecto.drop && mix ecto.create && mix ecto.migrate
MIX_ENV=test mix ecto.drop && MIX_ENV=test mix ecto.create && MIX_ENV=test mix ecto.migrateCI publishes a multi-registry image on every push to master:
ghcr.io/neiam/waxxdocker.io/neiam/waxxquay.io/neiam/waxx
Tags: latest (default branch), vX.Y.Z + X.Y (git tags), the branch name,
and the commit SHA.
docker pull ghcr.io/neiam/waxx:latest
docker run --rm -p 4000:4000 \
-e PHX_SERVER=true \
-e SECRET_KEY_BASE="$(openssl rand -base64 48)" \
-e PHX_HOST=localhost \
-e POSTGRES_HOST=host.containers.internal \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=waxx \
ghcr.io/neiam/waxx:latestOr set DATABASE_URL=ecto://user:pass@host/db instead of the discrete
POSTGRES_* variables.
The repo ships a Kubernetes deploy plan modelled on the one used for diogramos. The pieces:
Dockerfile— multi-stage build onhexpm/elixir:1.17.3-erlang-27.2-debian-bookworm-…, produces an Elixir release at/app/bin/waxx. Entry point goes throughtiniso SIGTERM closes Postgres connections cleanly.rel/env.sh.eex— long-name distribution fromPOD_IPso libcluster can connect peers aswaxx@<pod-ip>.rel/overlays/bin/{server,migrate}—serverstarts the release withPHX_SERVER=true;migraterunsWaxx.Release.migratefrom a running container without Mix.lib/waxx/release.ex—migrate / rollback / initops tasks callable viabin/waxx eval ….app.yml— Namespace, ServiceAccount + RBAC (pods + endpoints, for libcluster's K8s strategy), Deployment with 2 replicas (rolling update, max-surge 1, max-unavail 0), regular Service, headless Service for peer discovery, Traefik redirect-https Middleware, and Ingress with cert-manager.pg.yml— StackGres SGCluster with thecitextextension enabled (users.emailis acitextcolumn).secrets.example.yml.tmpl— template Secret (waxx-secrets) carryingSECRET_KEY_BASE,RELEASE_COOKIE, optionalSENTRY_DSN, and the fullSMTP_*+MAIL_FROM_*env set for the mailer. The.tmplextension keepskubectl apply -f .and Argo CD directory globs from accidentally applying the placeholder values (which look valid but crash the pod —SECRET_KEY_BASE=REPLACE_MEis only 10 bytes, Phoenix wants ≥64). The Postgres password comes from the StackGres-managedpostgres-waxxSecret in the same namespace..gitlab-ci.yml— Kaniko build to the container registry on pushes tomaster.
In prod, config/runtime.exs configures libcluster with
Cluster.Strategy.Kubernetes:
config :libcluster,
topologies: [
waxx: [
strategy: Cluster.Strategy.Kubernetes,
config: [
mode: :ip,
kubernetes_selector: "name=waxx",
kubernetes_service_name: "waxx-headless",
kubernetes_node_basename: "waxx",
kubernetes_namespace: "waxx",
polling_interval: 10_000
]
]
]Waxx.Application only starts Cluster.Supervisor when a topology is
configured, so dev/test don't try to join a cluster. Phoenix.PubSub
broadcasts (every :cards_changed / :workflow_changed event) fan
out automatically across connected nodes, so a drag on replica A
is visible in real time to a user on replica B.
# 1. Apply the database
kubectl apply -f pg.yml
# 2. Apply the app secret directly with real values (don't edit the
# .tmpl in place — its REPLACE_ME placeholders crash the pod).
SKB=$(openssl rand -base64 64 | tr -d '\n')
COOKIE=$(openssl rand -hex 32)
kubectl -n waxx create secret generic waxx-secrets \
--from-literal=SECRET_KEY_BASE="$SKB" \
--from-literal=RELEASE_COOKIE="$COOKIE" \
--from-literal=SENTRY_DSN="" \
--from-literal=SMTP_RELAY="" \
--dry-run=client -o yaml | kubectl apply -f -
# 3. Apply the rest
kubectl apply -f app.yml
# 4. Run migrations and seed the first user
kubectl -n waxx exec deploy/waxx -- /app/bin/migrate
kubectl -n waxx exec deploy/waxx -- /app/bin/waxx eval \
'Waxx.Release.init("admin@example.com", "a-strong-password")'Not yet specified.