Hof ("yard"/"courtyard" in German) is the meta-repo for the Hof platform — a small suite of self-hosted personal services. It doesn't contain any application code itself; it's docs plus git submodules pinning the service repos to the commits that make up the current release.
schloss— home page / launcherschlussel— auth: accounts, login, tokenskuvert— envelope budgetingtafel— task/project trackingzettel— markdown note-takingglocke— in-app notification center and delivery foundationschrank— file storage with nested foldersherold— webmail client for external IMAP/SMTP accountswachter— server resource monitoring (backend only; its admin UI lives insideschloss, see below)tor— reverse-proxy gateway all of the above sit behindschloss-ui— shared frontend components, consumed by every service's web appschloss-server-kit— shared backend auth/CORS kit, consumed by every service's API
Each is independently developed, tested, and deployed (its own CI, its own Docker images, its own issue tracker). This repo exists to answer "what versions of each service go together" and to hold cross-cutting planning docs that don't belong in any single service's history.
The in-app notification producer rollout is complete. Glocke centrally registers, validates, and renders the current event catalog; producers send domain data, not presentation text:
schlussel.security.password_changed.v1kuvert.goal.completed.v1kuvert.debt.paid_off.v1kuvert.envelope.overdrawn.v1tafel.task.due.v1tafel.project.completed.v1zettel.note.backlink_added.v1
Mutation-originated producers write events transactionally with their domain change. Tafel's clock-driven scanner atomically records the due occurrence and its outbox row. Every producer uses a leased, retained outbox with bounded retries; Glocke's durable inbox and notification uniqueness constraints make duplicate delivery idempotent. At processing time Glocke reads the recipient's current preference from Schlüssel and durably suppresses disabled or missing recipients. Glocke alone owns rendering, including trusted deployment-configured Kuvert and Tafel action origins; producer payloads cannot supply action URLs.
Every authenticated application header now includes the same accessible Glocke
bell. It fetches the exact unread count with the existing in-memory bearer token,
shows 99+ visually without losing the exact accessible count, polls while the
page is active, and refreshes after focus, connectivity recovery, or a Glocke
read/delete mutation. Requests use one validated browser-facing Glocke origin;
tokens never enter links, and Glocke's notification responses are private and
no-store with an exact platform-origin CORS allowlist.
Tafel's due scanner records persistent occurrence identities separately from the outbox, so pruning retained terminal delivery rows cannot re-emit an old due or overdue occurrence. The deployment uses five distinct directional HMAC secrets: one from each of Schlüssel, Kuvert, Tafel, and Zettel to Glocke, plus a separate Glocke-to-Schlüssel secret for recipient and preference lookup.
Browser Push is implemented and disabled by default. Schlüssel owns only a
global notifyBrowserPush switch on the account profile; Glocke owns
everything else - VAPID keys, per-browser subscriptions, a leased retry
worker around web-push, and a push-only service worker
(glocke/frontend/public/sw.js). Materialization gates the in-app row and
each active subscription's delivery row independently inside the same
fenced inbox write, so either channel can be on without the other. A push
notification carries only generic text and a trusted destination URL -
never the event's rendered title/body - and a click navigates and focuses an
existing Glocke tab before opening a new one. Subscriptions are matched to
the current browser by endpoint hash and bound to the Schlüssel session that
registered them. Logout revokes the server binding and visits Glocke to
unsubscribe that browser locally. The retry worker fences lease settlement,
re-checks current preferences and sessions, expires stale VAPID bindings,
deletes subscriptions on 404/410, and purges retained terminal deliveries.
Telegram bot/account-linking remains the one still-unimplemented channel.
Wächter separates its public admin API from a small authenticated host agent.
Only that agent receives the Docker socket and narrow host probes; the API
container runs non-root with a read-only filesystem and cannot issue Docker
requests directly. The agent exposes fixed list/stats/restart operations,
and restart additionally requires an explicit per-container
hof.wachter.restartable=true label. Schloss reports stale, unavailable, and
unknown samples instead of presenting collection failures as healthy zeros.
The platform keeps direct service exports and the platform archive separate.
Kuvert, Tafel, Zettel, Glocke, Schrank, and Herold expose synchronous
GET /exports/me JSON snapshots; their Settings pages can download those
responses directly. Schlüssel retains its synchronous GET /auth/export
JSON. Only Schlüssel's authenticated POST /auth/export-jobs API creates the
asynchronous ZIP containing snapshots from Schlüssel and all six consumer
services. Schrank and Herold exports are metadata-only: file bytes, message
content, attachments, and credentials are excluded.
For collection, Schlüssel mints a short-lived RS256 delegation for one exact
service audience. Services verify it through Schlüssel's JWKS and exact issuer
and require token_use: export, data:export scope, and nonempty subject, job,
and token IDs plus a non-expired numeric expiry. Delegations work only on that
service's /exports/me; ordinary routes and retained legacy exports reject
them. The verified subject defines
ownership, and clients cannot supply service URLs.
Every service reads a locally consistent snapshot when its own request runs.
There is no distributed transaction, so the files are not one platform-wide
point-in-time view; retrying failures keeps successful files and captures the
retried services later. A job with at least one success can publish a partial
ZIP. Its manifest.json records per-service status, attempts, paths, byte
counts, SHA-256 checksums, timestamps, and sanitized errors; failed response
bodies are not included.
Artifacts are private, owner-only, and short-lived (24 hours by default). Status and download responses use no-store/no-cache and nosniff headers. Creation is bounded by a per-user cooldown, retained-job and retained-byte caps, per-service and aggregate response limits, a global storage quota, and a filesystem free-space reserve. Exports contain sensitive profile, financial, task, note, and notification data; protect and delete downloaded files as appropriate. They exclude passwords/hashes, token and signing/HMAC material, runtime configuration, logs, worker leases, notification inbox payloads and hashes, internal audit state, other users, and services outside the fixed Schlüssel/Kuvert/Tafel/Zettel/Glocke/Schrank/Herold registry.
Deleting an account in Schlüssel creates a durable per-service cleanup saga before removing the identity. Kuvert, Tafel, Zettel, Glocke, Schrank, and Herold accept only short-lived, audience-bound deletion delegations, record a tombstone to reject old access tokens, and idempotently remove that subject's data. Schrank completes filesystem deletion through its retryable storage queue. Schlüssel tracks each target independently and retries bounded failures, so a temporary service outage cannot silently abandon user data.
Kuvert, Tafel, Zettel, and Glocke keep application code in backend/ and
frontend/. Schloss is frontend-only and keeps that frontend at its repo
root; Schlussel keeps its backend at its repo root and its frontend in
frontend/.
Only tor publishes a host port - everything else is reached through it by
subdomain. Where a service has its own REST API, its OpenAPI spec and a
Swagger UI viewer live at /docs in that service's own web app, visible to
admins only - except wachter, which has no web app of its own: its
Swagger UI is served from schloss instead, at /server-stats/docs.
| Service | Purpose | Internal ports | Public path | API docs |
|---|---|---|---|---|
schloss |
Home page / launcher | 80 (web) |
https://<domain>/ |
— (no API of its own) |
schlussel |
Auth: accounts, login, tokens, invites, admin | 4000 (api), 80 (web) |
https://auth.<domain>/ |
/docs (admin only) |
kuvert |
Envelope budgeting | 3001 (api), 80 (web) |
https://kuvert.<domain>/ |
/docs (admin only) |
tafel |
Task/project tracking | 3002 (api), 80 (web) |
https://tafel.<domain>/ |
/docs (admin only) |
zettel |
Markdown note-taking | 3003 (api), 80 (web) |
https://zettel.<domain>/ |
/docs (admin only) |
glocke |
In-app notification center and delivery foundation | 3004 (api), 80 (web) |
https://glocke.<domain>/ |
/docs (admin only) |
schrank |
File storage with nested folders | 3005 (api), 80 (web) |
https://schrank.<domain>/ |
/docs (admin only) |
herold |
Webmail client for external IMAP/SMTP accounts | 3006 (api), 80 (web) |
https://herold.<domain>/ |
/docs (admin only) |
wachter |
Server resource monitoring | 3007 (api only, no web app) |
none (no subdomain - reached via schloss's /wachter/* proxy) |
/server-stats/docs on schloss (admin only) |
tor |
Reverse-proxy gateway | 80/443 |
entry point for all of the above | — |
Glocke's direct frontend development server uses port 5177; production
traffic reaches its web container through Tor on port 80 like the other
full-stack apps.
ROADMAP.md— development history and status across all repos.- Twelve git submodules (
schlussel/,schloss/,kuvert/,tafel/,zettel/,glocke/,schrank/,herold/,wachter/,tor/,schloss-ui/,schloss-server-kit/), each pinned to a specific commit.
git clone --recurse-submodules git@github.com:zudaR107/Hof.git
# or, if you already cloned without --recurse-submodules:
git submodule update --init --recursiveSee tor/README.md — one
docker compose up from tor/ starts everything (schloss, schlussel,
kuvert, tafel, zettel, glocke, schrank, herold, wachter, and
the gateway itself) behind a single address, no ports to remember.
This repo is committed to rarely, by design — only when bumping a submodule pointer to a service's new release, or updating cross-cutting docs. Regular development happens in the submodule repos themselves, each with its own issue/PR workflow. Root CI validates the exact recursive gitlinks and Tor configuration contracts; it never substitutes service default branches for the pinned release graph.
AGPL-3.0 — see LICENSE. Each submodule carries its own copy of the same license.