- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board - Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api - Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme - PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven - Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
24 KiB
Havenllo implementation plan
1. Overview and goals
Havenllo will be a single-user kanban application for the Haven homelab. It will provide multiple boards, ordered lists, and ordered task cards without accounts, authentication, or external services. One Go HTTP server will expose a same-origin JSON API and serve the compiled Preact SPA from one container. Its only durable state will be a SQLite database stored on a 1Gi PVC.
The implementation will favour a polished day-to-day board: fast board loading, native card drag-and-drop, useful empty/loading states, keyboard-friendly inline editing, and a responsive horizontally scrolling kanban layout. It will not add an ORM, WebSockets, a separate frontend server, or a separate database service.
2. Proposed repository tree
.
├── .dockerignore # Excludes Git data, node_modules, local DBs, and build artefacts from Docker context.
├── .gitattributes # Enforces LF for Docker/YAML/workflow files edited from Windows.
├── .gitea/
│ └── workflows/
│ └── main.yaml # Gitea Actions: verify, multi-arch build/push, apply, and restart.
├── .gitignore # Ignores database, binaries, coverage, node_modules, and web/dist output.
├── Dockerfile # Three-stage Node + Go + distroless build for the one production image.
├── README.md # Local development, test, build, and Kubernetes deployment instructions.
├── go.mod # Module definition; direct dependency only on modernc.org/sqlite.
├── go.sum # Checksums generated by go mod tidy and committed.
├── cmd/
│ └── havenllo/
│ └── main.go # Configuration, database setup, and HTTP server lifecycle.
├── internal/
│ ├── api/
│ │ ├── handlers.go # Go 1.22 ServeMux routes and endpoint handlers.
│ │ ├── http.go # JSON, errors, ID parsing, and validation helpers.
│ │ ├── types.go # API DTOs and domain-to-response conversion.
│ │ └── handlers_test.go # HTTP contract tests against temporary SQLite.
│ ├── app/
│ │ ├── service.go # Transactional validation, ordering, moves, and board seeding.
│ │ └── service_test.go # Ordering, move, delete, and default-board tests.
│ ├── database/
│ │ ├── database.go # SQLite options, migrations, and initial seed data.
│ │ ├── migrations.go # Versioned schema migrations.
│ │ └── database_test.go # Migration/idempotency and foreign-key tests.
│ ├── domain/
│ │ └── models.go # Board, List, Card, snapshot, and domain errors.
│ └── store/
│ ├── sqlite.go # Parameterized SQL repository methods; no ORM.
│ └── sqlite_test.go # Persistence and ordering-query tests.
├── web/
│ ├── embed.go # Embeds dist/ into the Go binary and exposes an fs.FS.
│ ├── index.html # Vite HTML shell, title, theme-color, and root element.
│ ├── package.json # Preact runtime; Vite/TS/Preact preset development tools.
│ ├── package-lock.json # Generated locked npm dependency graph, committed.
│ ├── tsconfig.json # Strict TypeScript configuration.
│ ├── vite.config.ts # Preact plugin and deterministic web/dist output.
│ ├── dist/
│ │ └── .gitkeep # Placeholder for pre-build Go embedding; replaced in image builds.
│ └── src/
│ ├── main.tsx # Preact bootstrap and global error boundary.
│ ├── App.tsx # Board selection, fetch, optimistic state hook, and shell.
│ ├── api.ts # Typed fetch client, endpoint methods, and API error parsing.
│ ├── types.ts # TypeScript representations of resources and errors.
│ ├── board-state.ts # Reducer/selectors, pending mutations, rollback, and toast state.
│ ├── drag.ts # Native drag payload, drop-index, and position helpers.
│ ├── styles.css # Design tokens and responsive component styling.
│ └── components/
│ ├── Header.tsx # Wordmark, board picker, create board, and status indicator.
│ ├── BoardCanvas.tsx # List rail, empty state, and add-list affordance.
│ ├── ListColumn.tsx # List editing, count, drop zones, and inline card creation.
│ ├── CardItem.tsx # Draggable card, inline title edit, done control, actions.
│ ├── CardEditor.tsx # Description editor and destructive card action.
│ ├── ConfirmDialog.tsx # Accessible confirmation dialog for deletes.
│ └── Toast.tsx # Non-blocking mutation feedback.
└── deploy/
└── havenllo.yaml # PVC, Deployment, Service, and Ingress multi-document manifest.
No application source files will be created during this planning step. The
web/dist directory is ignored except for its tracked placeholder; the Docker
build replaces it with Vite output before Go evaluates the embed directive.
3. Data model and SQLite schema
IDs are SQLite INTEGER PRIMARY KEY values. position is a floating-point
sort key; records are ordered by (position, id). A new sibling receives the
current maximum plus 1024; a drag chooses a value between its new neighbours.
The service renumbers a sibling group to 1024 increments inside the same
transaction whenever positions become too close or invalid.
Timestamps are UTC RFC 3339 strings written by Go. SQLite foreign keys are enabled for every connection, and migrations run transactionally before the server accepts traffic.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
created_at TEXT NOT NULL,
position REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY,
board_id INTEGER NOT NULL,
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
position REAL NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY,
list_id INTEGER NOT NULL,
title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 240),
description TEXT NOT NULL DEFAULT '',
position REAL NOT NULL,
done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_boards_position ON boards(position, id);
CREATE INDEX IF NOT EXISTS idx_lists_board_position ON lists(board_id, position, id);
CREATE INDEX IF NOT EXISTS idx_cards_list_position ON cards(list_id, position, id);
Migration 1 atomically seeds a Haven board with To do, In progress, and
Done lists. Creating later boards seeds the same three lists. Deleting a board
cascades its lists and cards, but the API rejects deletion of the final board
with 409 Conflict so the app remains usable.
The database comes from HAVENLLO_DATABASE_PATH (default /data/havenllo.db).
Connection setup uses WAL, a 5-second busy timeout, foreign_keys=ON, and one
open connection. This single-replica deployment intentionally keeps SQLite a
safe, simple single-writer database on the NFS-backed PVC.
4. REST API contract
All endpoints are same-origin under /api, accept and return JSON, and use
integer resource IDs. Mutation requests require Content-Type: application/json.
Unknown fields are rejected; names and titles are trimmed; descriptions allow up
to 10,000 characters. PATCH DTOs use pointer fields so done: false is distinct
from an omitted field.
Every error has this shape:
{
"error": {
"code": "validation_error",
"message": "title is required"
}
}
Success statuses are 200, 201, and 204. The API returns 400 for malformed
JSON/IDs, 404 for absent records, 409 for final-board deletion, 415 for an
incorrect media type, and 422 for business validation. Unexpected database
errors are logged server-side and produce a non-sensitive 500 response.
Resource shapes
A board is { "id": 1, "name": "Haven", "position": 1024, "created_at": "2026-07-14T12:00:00Z" }.
A list is { "id": 10, "board_id": 1, "name": "To do", "position": 1024, "card_count": 2 }. card_count is included only in list collection responses.
A card is:
{
"id": 101,
"list_id": 10,
"title": "Back up the router config",
"description": "Export it after the firmware update.",
"position": 1024,
"done": false,
"created_at": "2026-07-14T12:00:00Z",
"updated_at": "2026-07-14T12:00:00Z"
}
The primary board-load response is a snapshot:
{
"board": { "id": 1, "name": "Haven", "position": 1024, "created_at": "2026-07-14T12:00:00Z" },
"lists": [
{ "id": 10, "board_id": 1, "name": "To do", "position": 1024, "cards": [] }
]
}
Health
| Method | Path | Behaviour and response |
|---|---|---|
GET |
/api/health |
Pings SQLite and returns 200 {"status":"ok"}; used by Kubernetes probes. |
Boards
| Method | Path | Request body | Success response |
|---|---|---|---|
GET |
/api/boards |
— | 200 {"boards":[Board...]}, ordered by position. |
POST |
/api/boards |
{"name":"Homelab"} |
201 BoardSnapshot with three starter lists. |
GET |
/api/boards/{boardID} |
— | 200 BoardSnapshot with lists and cards in render order. |
PATCH |
/api/boards/{boardID} |
{"name":"…","position":2048}; either field optional |
200 {"board":Board}. |
DELETE |
/api/boards/{boardID} |
— | 204, unless it is the final board (409). |
Lists
| Method | Path | Request body | Success response |
|---|---|---|---|
GET |
/api/boards/{boardID}/lists |
— | 200 {"lists":[List...]}, with card counts but not card bodies. |
POST |
/api/boards/{boardID}/lists |
{"name":"Waiting","position":4096}; position optional |
201 {"list":List}. |
GET |
/api/lists/{listID} |
— | 200 {"list":List,"cards":[Card...]}. |
PATCH |
/api/lists/{listID} |
{"name":"…","board_id":2,"position":1024}; all optional |
200 {"list":List}. |
DELETE |
/api/lists/{listID} |
— | 204; its cards are cascade-deleted after UI confirmation. |
Cards
| Method | Path | Request body | Success response |
|---|---|---|---|
GET |
/api/lists/{listID}/cards |
— | 200 {"cards":[Card...]}, ordered by position. |
POST |
/api/lists/{listID}/cards |
{"title":"Replace UPS battery","description":"…","position":4096}; description/position optional |
201 {"card":Card}. |
GET |
/api/cards/{cardID} |
— | 200 {"card":Card}. |
PATCH |
/api/cards/{cardID} |
{"title":"…","description":"…","done":true,"list_id":11,"position":1536}; all optional |
200 {"card":Card}. |
DELETE |
/api/cards/{cardID} |
— | 204. |
The browser calculates target position from adjacent cards at the drop location. The service independently validates it, normalizes sibling positions when needed, and returns the authoritative resource. A card list move and position update happen in one transaction.
5. Frontend architecture and interaction design
The frontend uses Preact, TypeScript, and Vite with only Preact and the Preact
Vite preset as dependencies. It uses no component, state, icon, or drag library.
web/embed.go exposes the completed Vite output as an embedded fs.FS, so the
SPA and API are always same-origin and need no CORS configuration.
App loads boards, restores the selected board from localStorage when it is
still valid (otherwise picks the first), and fetches one BoardSnapshot. Its
useReducer-based board state owns the snapshot, selection, pending mutation
IDs, rollback snapshots, and toast/error state. Every create/edit/move action
first performs an immutable optimistic update, calls api.ts, reconciles with
the canonical response, and restores state with an error toast if it fails.
Controls disable only for their pending resource, rather than freezing the board.
Header contains the Havenllo wordmark, board picker, create-board action, and
small connection/status feedback. BoardCanvas maps lists to columns and ends
with an inline Add list affordance. ListColumn provides list-name inline edit,
card count, card drop zones, and inline add-card input. CardItem provides
click-to-edit title, a done control, and a small actions control; CardEditor
edits descriptions and hosts destructive actions. ConfirmDialog is required
before deleting a card, list, or board, and Toast gives non-blocking feedback.
Cards use native HTML5 drag-and-drop. The drag payload holds card and source-list IDs. Drop zones before every card plus one at the end provide an unambiguous target index. Drag-over uses an accent outline and local preview; drop calculates the new fractional position, updates optimistically, and sends one PATCH. A failed call restores the pre-drag snapshot. The usual add/edit/done controls stay fully usable on phones, while the board uses a horizontally scrolling rail, 280px minimum columns, and 44px touch targets; this remains useful on mobile browsers with limited native DnD support.
Plain CSS in styles.css will use variables and deliberately named component
classes. The visual system is deep slate/navy with restrained teal/indigo accents,
12–16px rounded surfaces, subtle borders, layered soft shadows, system font
stacks, clear focus rings, and reduced-motion support. Done cards, skeletons,
empty states, inline save/cancel affordances, and error states will feel designed
rather than like bare CRUD.
6. Backend architecture
cmd/havenllo/main.go reads HAVENLLO_LISTEN_ADDR (default :8080) and
HAVENLLO_DATABASE_PATH (default /data/havenllo.db), configures standard
library logging, opens/migrates SQLite, constructs the service and HTTP handler,
and gracefully shuts down on SIGINT/SIGTERM.
database owns parent-directory creation, SQLite connection options, and ordered
migrations. store contains parameterized SQL and record scanning, with no ORM.
app.Service owns cross-record validation, default list creation, ordering and
renumbering, and final-board rules. This keeps every state-changing operation
transactional and independently testable.
api uses Go 1.22 http.ServeMux method/path patterns and PathValue. It
decodes bounded JSON bodies with DisallowUnknownFields, maps typed domain errors
to the documented JSON shape, and never exposes SQL errors. It registers /api/
before static files: hashed Vite assets are served directly, and embedded
index.html is a fallback only for non-API SPA routes. An unknown API route
always produces JSON 404, never the SPA HTML. CORS headers are intentionally not
set because all browser traffic is same-origin.
Tests cover migration/seed behaviour, cascade effects, position normalization, invalid moves, final-board protection, JSON validation, and essential status/ response shapes. Temporary SQLite files and an injectable static filesystem keep tests independent of the PVC and production frontend bundle.
7. Dockerfile strategy
The Dockerfile has three stages:
- A
node:22-alpinefrontend stage copies package manifests first, runsnpm ci, then buildsweb/withnpm run buildintoweb/dist. - A
golang:1.24-alpinestage copiesgo.mod/go.sum, runsgo mod download, copies source and the frontend stage'sweb/dist, then runsCGO_ENABLED=0 go build. Buildx argumentsTARGETOSandTARGETARCHare passed to the build so the pure-Gomodernc.org/sqlitebinary targets each requested architecture without CGO. gcr.io/distroless/static-debian12:nonrootreceives only the statically linkedhavenllobinary, exposes 8080, and runs as its non-root user.
The Dockerfile declares BUILDPLATFORM, TARGETOS, and TARGETARCH for clean
linux/amd64,linux/arm64 remote Buildx builds. .dockerignore excludes local
web/node_modules, existing web/dist, Git metadata, test output, and *.db.
The stage-to-stage copy supplies the actual bundle, so ignoring any local dist
cannot break embedding. The final image contains no Node runtime, Go toolchain,
shell, npm cache, source tree, or database.
8. Exact Kubernetes manifest
deploy/havenllo.yaml will contain the following four documents. Each resource
explicitly targets the existing default namespace. One replica and a Recreate
strategy prevent concurrent SQLite writers or a conflicting RWO attachment during
an update. fsGroup matches the non-root distroless user so the provisioned NFS
volume remains writable.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: havenllo-data
namespace: default
annotations:
nfs.io/storage-path: havenllo-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: nfs-client
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: havenllo
namespace: default
labels:
app.kubernetes.io/name: havenllo
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: havenllo
template:
metadata:
labels:
app.kubernetes.io/name: havenllo
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
containers:
- name: havenllo
image: git.ivanch.me/ivanch/havenllo:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: HAVENLLO_DATABASE_PATH
value: /data/havenllo.db
- name: HAVENLLO_LISTEN_ADDR
value: :8080
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumes:
- name: data
persistentVolumeClaim:
claimName: havenllo-data
---
apiVersion: v1
kind: Service
metadata:
name: havenllo
namespace: default
labels:
app.kubernetes.io/name: havenllo
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: havenllo
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: havenllo
namespace: default
annotations:
cert-manager.io/cluster-issuer: internal-ca
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- havenllo.haven
secretName: havenllo-tls
rules:
- host: havenllo.haven
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: havenllo
port:
number: 8080
There is no ConfigMap or Secret: the only environment values are non-sensitive,
and all durable state is the mounted SQLite file. The image needs no pull secret
when the cluster is already configured for git.ivanch.me; if that is not true,
the implementation will reference an existing pull-secret by name rather than
commit registry credentials.
9. Build and deploy flow
.gitea/workflows/main.yaml will run on pushes to main and manual dispatch.
It will keep YAML files LF-only via .gitattributes and use descriptive name:
values for every Gitea job.
- A
verifyjob onrunner-slim-amd64checks out the repository, runs Go tests, runsnpm ciand the TypeScript/Vite production build, and performs a client-sidekubectl apply --dry-run=clientvalidation of the manifest when kubectl is available. - A
buildjob useshttps://git.ivanch.me/ivanch/pipeline-actions/build-and-push@mainwithimage: git.ivanch.me/ivanch/havenllo,image_tag: latest, registry-password and remote-builder SSH-key secrets. It retains the action's defaultlinux/amd64,linux/arm64platforms, builds ondocker-build.haven, and pushesgit.ivanch.me/ivanch/havenllo:latest. - A dependent
deployjob useshttps://git.ivanch.me/ivanch/pipeline-actions/kubectl-apply@mainwithmanifest: deploy/havenllo.yamland${{ secrets.KUBE_CONFIG }}. It then useshttps://git.ivanch.me/ivanch/pipeline-actions/deploy-restart@mainwithdeployment_name: havenllo,namespace: default, and the same kubeconfig. Applying before restart reconciles PVC/Service/Ingress changes before thelatestimage is restarted. - Deployment verification is
kubectl -n default rollout status deployment/havenllo, followed bykubectl -n default get certificate,ingress havenlloand openinghttps://havenllo.haven. cert-manager createshavenllo-tlsthrough the existinginternal-caClusterIssuer.
The Gitea repository requires REGISTRY_PASSWORD, SSH_KEY_DOCKERBUILD, and
KUBE_CONFIG secrets. No secret values are committed.
10. Ordered implementation checklist
- Add
.gitignore,.dockerignore,.gitattributes, and README. Confirm no local database or generated frontend build is tracked and workflow YAML will remain LF-only on Windows. - Add
go.modwith onlymodernc.org/sqlite, rungo mod tidy, and define the domain models and errors. - Implement SQLite connection options, migration tracking, the schema above,
and one-time
Haven/starter-list seeding. Add migration and foreign-key tests. - Implement parameterized SQLite store methods and the transactional service: validation, float ordering/normalization, list/card moves, board starter lists, cascade-aware deletion, and final-board protection. Add store/service tests.
- Implement the standard-library API router, JSON/error helpers, health check, all documented handlers, and graceful process entry point. Add HTTP contract tests for normal, invalid, missing, and conflict cases.
- Scaffold Vite/Preact, the typed client, reducer-based optimistic board state, board selector persistence, and loading/error states.
- Implement the visual components and CSS system: header, list rail, inline creation/editing, count badges, done state, descriptions, confirmations, toasts, empty states, and responsive interaction styling.
- Add native card drag/drop previews and drop zones for same-list and cross-list moves. Manually test error rollback, keyboard controls, and mobile-sized views.
- Add
web/embed.goand Go static-file/SPA fallback routing. Verify a production frontend build is embedded and API misses return JSON rather than HTML. - Add the multi-stage Dockerfile; build locally for the host platform and verify
the container creates/uses
/data/havenllo.db, serves/, and passes/api/healthwith a read-only root filesystem. - Add the exact deployment manifest and Gitea workflow using the documented pipeline-actions URLs. Validate YAML, a client-side apply, and LF-only files.
- Push
main; allow the multi-architecture image to build and deploy; verify rollout, certificate, and ingress; then smoke-test create, edit, complete, drag, refresh persistence, and deletion confirmation athavenllo.haven.