Self-hosted multi-tenant ZenNotes vault provisioning engine
  • TypeScript 59.1%
  • Svelte 36.2%
  • Shell 3.1%
  • Dockerfile 1.3%
  • HTML 0.3%
Find a file
themodrnhakr cab0399620
All checks were successful
Build and Publish Docker Images / Build manager-app (push) Successful in 1m13s
Build and Publish Docker Images / Build caddy-oidc (push) Successful in 41s
Use PROJECT_ROOT env var instead of hardcoded /project-root
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.
2026-06-23 18:18:56 -05:00
.forgejo/workflows Revert to Docker workflow with static CLI binary 2026-06-23 14:21:11 -05:00
.reference Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
.vscode Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
docs Fix Docker Compose for one-command spin-up 2026-06-23 13:00:23 -05:00
manager-app Fix manager-app port mismatch: Dockerfile + compose now set PORT=5173 2026-06-23 16:52:35 -05:00
ops Fix Docker Compose for one-command spin-up 2026-06-23 13:00:23 -05:00
src Use PROJECT_ROOT env var instead of hardcoded /project-root 2026-06-23 18:18:56 -05:00
static Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
.dockerignore Fix Docker Compose for one-command spin-up 2026-06-23 13:00:23 -05:00
.env.example Remove CADDY_COOKIE_SECRET, reuse CADDY_OIDC_SECRET for both 2026-06-23 14:56:34 -05:00
.gitignore Fix Forgejo runner label: ubuntu-latest -> docker 2026-06-23 13:37:38 -05:00
.npmrc Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
bun.lock Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
Caddyfile Strip TLS from Docker Caddy entirely, HTTP only 2026-06-23 16:45:59 -05:00
Caddyfile.build Remove trusted_proxies and local_certs from Caddyfile 2026-06-23 15:33:21 -05:00
DESIGN.md Fix Docker Compose for one-command spin-up 2026-06-23 13:00:23 -05:00
docker-compose.yml Use PROJECT_ROOT env var instead of hardcoded /project-root 2026-06-23 18:18:56 -05:00
package.json Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
PLAN.md Fix Docker Compose for one-command spin-up 2026-06-23 13:00:23 -05:00
README.md Move Quick Start to top, add OIDC Configuration section 2026-06-23 14:40:33 -05:00
REPO.md Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
setup.sh Phase 1: Add Caddy infrastructure, Docker Compose, setup script, and Keycloak docs 2026-06-23 12:14:51 -05:00
tsconfig.json Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
vite.config.ts Add foundation scaffold for ZenNotes provisioning engine 2026-06-23 12:13:29 -05:00
WORKFLOW.md Add Forgejo Actions workflow to build and publish Docker images 2026-06-23 13:09:56 -05:00

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:packages scope, 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:

  1. Builds manager-app — the SvelteKit + Effect.ts orchestration backend
  2. Builds caddy-oidc — Caddy with the OIDC plugin compiled in
  3. 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)