- TypeScript 59.1%
- Svelte 36.2%
- Shell 3.1%
- Dockerfile 1.3%
- HTML 0.3%
The bind mount source must be a valid HOST path, not a container path. Using /project-root (a container mount) as the bind mount source meant Docker daemon couldn't find it on the host. PROJECT_ROOT is set by docker-compose via /Users/ericrumsey/Source/zennotes-manager, which gives the absolute host path. Relative VAULT_ROOT paths are resolved against this to produce valid host paths for Docker bind mounts. |
||
|---|---|---|
| .forgejo/workflows | ||
| .reference | ||
| .vscode | ||
| docs | ||
| manager-app | ||
| ops | ||
| src | ||
| static | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .npmrc | ||
| bun.lock | ||
| Caddyfile | ||
| Caddyfile.build | ||
| DESIGN.md | ||
| docker-compose.yml | ||
| package.json | ||
| PLAN.md | ||
| README.md | ||
| REPO.md | ||
| setup.sh | ||
| tsconfig.json | ||
| vite.config.ts | ||
| WORKFLOW.md | ||
ZenNotes Manager
A self-hosted system that lets users spin up isolated, single-user ZenNotes vaults on demand. Each vault runs in its own Docker container with its own data directory, auth token, and subdomain.
User → Caddy (OIDC auth) → Manager App (SvelteKit) → Docker → ZenNotes containers
Quick Start
Prerequisites
- Docker (or Podman) — for running the infrastructure and instance containers
- An OIDC provider — Keycloak, Authentik, Google Workspace, Azure AD, etc.
- A domain — with DNS pointing to your host (e.g.,
notes.example.com) - Forgejo API token — with
write:packagesscope, for CI/CD
One-time Setup
# 1. Clone the repo
git clone https://git.rumseyfamily.cloud/ehrumsey/zennotes-manager
cd zennotes-manager
# 2. Configure environment
cp .env.example .env
# Edit .env with:
# DOMAIN=example.com # your domain (NOT including "notes.")
# CADDY_OIDC_PROVIDER=... # your OIDC issuer URL
# CADDY_OIDC_CLIENT_ID=caddy-client # OIDC client ID
# CADDY_OIDC_SECRET=... # OIDC client secret
# CONTAINER_SOCKET_PATH=/var/run/docker.sock
# 3. Prepare host directories
sudo bash setup.sh
# 4. Start everything
docker compose up -d
# 5. Open the dashboard
open https://notes.example.com/
Development
bun install
bun run dev # starts at http://localhost:5173
How It Works
Authentication Flow
Browser → notes.example.com → Caddy OIDC challenge → Keycloak login
→ callback with token → Caddy injects X-User-* headers
→ SvelteKit dashboard loads with user identity
Caddy handles all auth at the edge via OIDC. Once authenticated, it forwards user identity to the backend via X-User-Preferred-Username, X-User-Email, and X-User-Roles headers.
Vault Lifecycle
| Action | What Happens |
|---|---|
| Create | Validate slug → generate 256-bit token → create container with labels + env vars → wait for HTTP readiness → return URL |
| Open | Manager reads token from Docker label → POSTs to ZenNotes login → relays Set-Cookie → 302 redirect to subdomain |
| Stop/Start | docker stop/start via Dockerode |
| Destroy | docker stop (graceful, 10s) → docker rm (preserves vault data on disk) |
Each vault gets: its own container, subdomain (zen-<user>-<slug>.notes.example.com), 256-bit auth token, isolated vault directory, and 256MB/0.5CPU resource limits.
Subdomain Routing
Subdomains are used instead of path prefixes because ZenNotes hardcodes its session cookie to Path=/api. With subdomain routing, the cookie path matches every API request naturally.
notes.example.com → Manager App (port 5173)
*.notes.example.com → ZenNotes container (port 7878)
zen-johndoe-work.notes... → That user's specific vault
OIDC Configuration
The system uses OpenID Connect (OIDC) at the Caddy edge to authenticate users before they reach the management panel.
Supported Providers
Any OIDC-compliant provider works. Common options:
| Provider | Setup Complexity | Notes |
|---|---|---|
| Keycloak | Medium | Self-hosted, full control. Recommended. |
| Authentik | Medium | Self-hosted alternative to Keycloak |
| Google Workspace | Easy | Use Google as the identity provider |
| Azure AD / Entra ID | Easy | Microsoft's identity platform |
| Auth0 | Easy | SaaS identity platform |
| Pocket ID | Easy | Lightweight self-hosted OIDC provider |
Step 1: Register a Client
Create an OIDC client / application in your provider with these settings:
| Setting | Value |
|---|---|
| Client ID | caddy-client (or any string) |
| Client authentication | Client secret (not public) |
| Grant type | Authorization code |
| Redirect URI | https://notes.yourdomain.com/callback |
| Scopes | openid, profile, email |
Step 2: Configure Roles
Define two roles for authorization:
| Role | Purpose |
|---|---|
zennotes-user |
Can create and manage their own vaults |
zennotes-admin |
Can view and manage all vaults across all users |
If your provider can't add custom roles, the system defaults to treating all authenticated users as zennotes-user. Admin access requires the role to be present.
Step 3: Map Claims
Configure your provider to include user info in the ID token:
| Claim | Header | Example |
|---|---|---|
preferred_username |
X-User-Preferred-Username |
johndoe |
email |
X-User-Email |
john@example.com |
realm_roles / groups |
X-User-Roles |
zennotes-user,zennotes-admin |
Step 4: Configure .env
DOMAIN=example.com
CADDY_OIDC_PROVIDER=https://keycloak.example.com/realms/your-realm
CADDY_OIDC_CLIENT_ID=caddy-client
CADDY_OIDC_SECRET=your-client-secret
Detailed Guide
For a full step-by-step Keycloak setup with screenshots, see docs/keycloak-setup.md.
System Architecture
[ OIDC Provider ]
│
(Auth Challenge)
│
▼
┌──────────────────────┐
│ Caddy Ingress │
│ (TLS + OIDC + │
│ subdomain routing) │
└──────┬──────────┬─────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ ZenNotes │ │ ZenNotes │
│ Instance 1 │ │ Instance 2 │
│ :7878 │ │ :7878 │
└──────────────┘ └──────────────┘
┌──────────────────────────────────┐
│ Manager Container │
│ SvelteKit + Effect.ts + Docker │
│ /var/run/docker.sock (mounted) │
│ /opt/zennotes/vaults/ (mounted) │
└──────────────────────────────────┘
Components
| Component | Role | Details |
|---|---|---|
| Caddy | TLS termination, OIDC auth, subdomain routing | Custom build with relvacode/caddy-oidc plugin. Auto-provisions TLS via Let's Encrypt. |
| OIDC Provider | Identity provider | Any OIDC-compliant server (Keycloak, Authentik, Google, etc.) |
| Manager App | Orchestration, dashboard, auth proxy | SvelteKit 5 + Effect.ts. Reads user from Caddy headers. Manages containers via Docker socket. |
| ZenNotes Instance | Single-user notes vault | Go server + embedded SPA. Each has one vault, one auth token. UID 65532. Read-only rootfs. |
| Docker | Container lifecycle | All containers on notes-net bridge. No host port exposure for instances. |
CI / CD
Every push to main triggers a Forgejo Actions workflow (.forgejo/workflows/build-images.yml) that:
- Builds
manager-app— the SvelteKit + Effect.ts orchestration backend - Builds
caddy-oidc— Caddy with the OIDC plugin compiled in - Publishes both to the Forgejo Container Registry at
git.rumseyfamily.cloud/ehrumsey/zennotes-manager/{name}:latest
The docker-compose.yml pulls these pre-built images by default. To build locally:
docker compose build
docker compose up -d
Documentation Index
| Document | What It Covers |
|---|---|
DESIGN.md |
Full architecture, routing rationale, security model, config reference |
PLAN.md |
Implementation plan with task tracking |
WORKFLOW.md |
Agent onboarding guide |
REPO.md |
Version control with jj |
docs/keycloak-setup.md |
Step-by-step Keycloak OIDC configuration |
docs/podman-notes.md |
Running with Podman instead of Docker |
ops/backup.md |
Backup procedures using restic |
ops/recovery.md |
Disaster recovery scenarios |
ops/upgrade.md |
Upgrading ZenNotes image and manager app |
ops/monitoring.md |
Key metrics, health checks, alerting |
ops/testing.md |
Manual integration test scenarios |
Security Model
| Layer | Protection |
|---|---|
| Edge auth | Caddy OIDC challenges all requests before they reach the manager app |
| Per-instance auth | Each ZenNotes container has its own 256-bit bootstrap token |
| Container isolation | --cap-drop ALL, --read-only --tmpfs /tmp, --security-opt no-new-privileges |
| Network isolation | All containers on isolated notes-net bridge. No host port exposure |
| Resource limits | 256MB RAM, 0.5 CPU per instance container |
| Data isolation | Vault directories namespaced per user |
| Session protection | HttpOnly, SameSite=Strict, Secure cookies on all ZenNotes instances |
| Rate limiting | Exponential backoff on failed login attempts (1s → 60s cap) |