Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ bookstack/db/
bookstack/config/
forgejo/data/
wanderer/data/
# HedgeDoc (docs.mathewcsims.uk): its Postgres datadir, and the uploads
# directory holding every image pasted into a note. uploads/ is user data with
# no copy anywhere in git, so kopia-mac/backup.sh is its only backup — see
# docs/compose.yaml.
docs/pgdata/
docs/uploads/

# Paperless: the SQLite database, Tantivy full-text index and trained
# classifier. Deliberately local disk, never the NAS — see
# paperless/compose.yaml for why that split is not negotiable. The document
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ in front of the lot.
| [Wanderer](https://github.com/open-wanderer/wanderer) | `wanderer.mathewcsims.uk` | Mac (self-hosted GPS trail/cycle-ride log — GPX/FIT/TCX/KML import; Meilisearch + PocketBase sidecars; posts a Memo to Owl on every new ride via a PocketBase-realtime relay) |
| [Immich](https://immich.app) | `immich.mathewcsims.uk` | **slartibartfast** (self-hosted photo/video library with local CLIP semantic search + face recognition — first app on the third host; LAN/tailnet-only, local accounts, no public sharing) |
| [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) | `paperless.mathewcsims.uk` | Mac (personal document store — letters, medical, certificates; OCR on ingest, LAN-only. Document blobs live on the NAS over CIFS; the SQLite database and search index stay on local disk. **Deliberately not wired to LiteLLM** — personal medical/legal documents have no business on an employer's GCP project) |
| [HedgeDoc](https://hedgedoc.org) | `docs.mathewcsims.uk` | Mac (personal Markdown authoring — real-time notes, per-note permissions, document list; LAN/tailnet-only **and** account-based, since the LAN gate admits any tailnet device but isn't an identity. Notes default to owner-only; accounts created deliberately via `scripts/docs-add-user.sh`, no self-registration, no guest access) |
| [LiteLLM](https://github.com/BerriAI/litellm) | `litellm.possum-prometheus.ts.net` | **slartibartfast** (OpenAI-compatible proxy in front of employer-funded Gemini Enterprise Agent Platform (formerly Vertex AI) — **tailnet-only** via a Tailscale sidecar tagged `personal` — no public hostname, no DNS record, not behind Caddy; ADC auth, no service-account key) |

### Decommissioned
Expand Down Expand Up @@ -79,6 +80,7 @@ internet → DrayTek router → Pi (Caddy, terminates HTTPS, routes by hostname)
├─ owl.mathewcsims.uk → Mac
├─ author.mathewcsims.uk → Mac (LAN clients only)
├─ paperless.mathewcsims.uk → Mac (LAN clients only)
├─ docs.mathewcsims.uk → Mac (LAN clients only)
└─ fj.mathewcsims.uk → Mac (LAN clients only;
git-over-SSH bypasses
Caddy entirely, port 2222)
Expand Down Expand Up @@ -208,6 +210,9 @@ forgejo/ compose.yaml and data (Mac — self-hosted git remote, LA
contact-sync/ cross-provider contact sync engine + launchd job (Mac —
data lives at ~/contact-sync, store pushed to Forgejo)
wanderer/ compose.yaml and data (Mac — GPS trail/cycle-ride log)
docs/ compose.yaml, Postgres and uploads (Mac — HedgeDoc,
personal Markdown authoring, LAN-only; uploads/ holds
every pasted image and exists only here + Kopia)
pi-reverse-proxy/ Caddy reverse proxy (Pi — deployed via scp + docker compose)
autostart/ launchd auto-start for podman on the Mac
scripts/ deploy tooling that fetches secrets from Proton Pass
Expand Down
117 changes: 117 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ section below says which.
| `bookstack/db/` | **Mac** | **BookStack's MariaDB datadir** |
| `forgejo/compose.yaml` | **Mac** | Forgejo (git + issues), SQLite; LAN-only (`fj.mathewcsims.uk`) plus direct git-over-SSH on port 2222; reads secrets from Proton Pass |
| `forgejo/data/` | **Mac** | **your repos, SQLite DB, SSH host keys live here** |
| `docs/compose.yaml` | **Mac** | HedgeDoc — personal Markdown authoring (`docs.mathewcsims.uk`); LAN/tailnet-only *and* per-user login; Postgres sidecar; reads `POSTGRES_PASSWORD` from Proton Pass |
| `docs/pgdata/` | **Mac** | **your notes live here** (HedgeDoc's Postgres datadir) |
| `docs/uploads/` | **Mac** | **every image pasted into a note lives here** — gitignored, so Kopia is the only copy |
| `contact-sync/` | **Mac** | hub-and-spoke contact sync engine (Proton/Google/2× Microsoft) — `sync.py` + per-provider spoke modules; canonical vCard store lives outside the repo at `~/contact-sync/store/` (its own private Forgejo repo); daily launchd job |
| `ntfy/compose.yaml` | **Pi** | self-hosted push notifications, on trial alongside Discord; auth default-deny, fed by Apprise |
| `ntfy/data/` | **Pi** | **auth DB, message cache, attachments live here** |
Expand Down Expand Up @@ -6343,6 +6346,120 @@ PDFs) ever outgrows the cost of a second copy.

---

## HedgeDoc / Docs (https://docs.mathewcsims.uk) — LAN-only, runs on the Mac

Personal Markdown authoring space. Real-time notes, a document list, per-note
permissions, and proper accounts. Friends and family on the tailnet can be given
accounts.

### Why it has BOTH a LAN gate and its own login

The Caddy gate admits any device on the LAN or the tailnet, which is the
availability wanted — but it is not an *identity*. It cannot distinguish one
tailnet device from another, and these are personal drafts. HedgeDoc's own
accounts supply the identity, and notes default to owner-only.

### Why HedgeDoc and not Etherpad

An Etherpad instance was built for this and torn down the same day. Its
`requireAuthentication` is HTTP Basic only — a native browser prompt, credentials
cached by the browser, and no usable logout — it has **no per-note permissions
at all**, and its own OIDC provider cannot serve pad login (core accepts only
Basic, and the provider's endpoints sit behind that same gate). None of that was
fixable by configuration. Two bugs found along the way were filed upstream
([ether/etherpad#8109](https://github.com/ether/etherpad/issues/8109),
[ether/ep_comments_page#454](https://github.com/ether/ep_comments_page/issues/454)).

### The trap: HedgeDoc builds ABSOLUTE URLs

Every asset URL comes from `CMD_DOMAIN` + `CMD_PROTOCOL_USESSL` +
`CMD_URL_ADDPORT`, **not** from the incoming request. Set them to the
container's values instead of the public ones and you get a completely unstyled
page with every asset 404ing. They must describe how a *browser* reaches this:
`docs.mathewcsims.uk`, SSL true, no port. This fails loudly and immediately, so
an unstyled page after a config change means look here first.

### Accounts

Self-registration is off, so accounts only exist because you made them:

```
./scripts/docs-add-user.sh someone@example.com
```

That prints a generated password once and stores nothing — put it in Proton
Pass or hand it over. Remove one with `./scripts/docs-add-user.sh --delete
someone@example.com` (which does **not** delete their notes).

**The permission model, and its limit.** Each note is owner-controlled with six
levels: Freely, Editable, Limited, Locked, Protected, and Private. New notes are
**Private** (`CMD_DEFAULT_PERMISSION=private`) — verified that a private note
returns **403** to a different signed-in account and does not appear in that
account's History. But permissions are owner / all-signed-in / guests: there is
**no per-person sharing**. Sharing a note shares it with everyone who has an
account.

### /status and /metrics are blocked at the proxy

HedgeDoc serves both without authentication, exposing note counts,
registered-user counts and who is online. Caddy `abort`s them (verified: 200 for
`/`, connection closed for `/status` and `/metrics`). Two consequences:

- The container healthcheck reaches `/status` on `127.0.0.1` **inside** the
container, so it is unaffected.
- Uptime Kuma therefore monitors `/` instead — see below.

### Monitoring

| Field | Value |
|---|---|
| Monitor type | `HTTP(s) - Keyword` |
| Friendly name | `Docs` |
| URL | `https://docs.mathewcsims.uk/` |
| Keyword | `HedgeDoc` |

`/` returns 200 without authentication and contains that string (verified), so a
keyword monitor works even though the app is behind a login. Kuma runs on the
Pi, which is in `private_ranges`, so the LAN gate is not an obstacle.

The `dump-databases.sh` post-dump health-check loop needs no special handling —
it already treats 4xx as healthy and only alerts on 5xx or no response.

### The healthcheck uses node, not wget

The HedgeDoc image ships **no wget, curl, nc or python3** — only node. A
wget-based healthcheck would report the container permanently unhealthy while
the service was fine (a mistake already made once on the Etherpad instance this
replaces). It also uses `127.0.0.1`, not `localhost`, because localhost can
resolve to `::1` first while the app listens on IPv4.

### To bring it up on a fresh machine

1. Create the Pass item once, under your own pass-cli session (agent tokens are
read-only for item creation):
```
./scripts/pass-create-docs-secrets.sh
```
2. Deploy:
```
./scripts/pass-deploy.sh docs
```
Never a bare `podman compose up -d` — `POSTGRES_PASSWORD` would be empty and
Postgres would initialise with trust authentication.
3. Create your account with `./scripts/docs-add-user.sh`.
4. Add the DNS records and the Caddy block (see the general recipe below), copy
the Caddyfile to the Pi and `docker compose restart caddy`.

### Backups

`docs/pgdata` is dumped nightly by `scripts/dump-databases.sh` (`dump_postgres
docs-postgres hedgedoc hedgedoc docs`). **`docs/uploads/` is a separate Kopia
source and matters just as much** — it holds every image pasted into a note, it
is gitignored, and it exists nowhere else. A restore without it gives you notes
full of broken images.

---

## Adding another app (the general recipe)

Three patterns, depending on where the app runs:
Expand Down
25 changes: 25 additions & 0 deletions docs/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Template only — there is NO real .env for this app.
#
# This value lives as a "Docs" item in the "Self-Hosted Secrets" Proton Pass
# vault, and scripts/pass-deploy.sh exports it into the deploy process's
# environment at `podman compose up` time. Nothing is written to disk. This
# file exists purely to record which field the Pass item must contain.
#
# Create the item once with:
# ./scripts/pass-create-docs-secrets.sh
#
# Deploy with:
# ./scripts/pass-deploy.sh docs
#
# Never `podman compose up -d` directly. POSTGRES_PASSWORD would resolve to an
# empty string, which for the postgres image means it initialises the cluster
# with trust authentication — every container on the compose network could then
# read the database without a password, and you would not be told.

# Postgres password for the hedgedoc role. Consumed twice: by the postgres
# service (POSTGRES_PASSWORD) and by HedgeDoc's CMD_DB_URL connection string.
POSTGRES_PASSWORD=

# NOTE: user account passwords are NOT here. HedgeDoc accounts are created with
# ./scripts/docs-add-user.sh, which prints a generated password once and stores
# nothing — put it straight into Proton Pass yourself.
167 changes: 167 additions & 0 deletions docs/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# HedgeDoc (https://hedgedoc.org) — personal Markdown authoring space at
# docs.mathewcsims.uk. Real-time collaborative notes with per-note permissions,
# a document list, and proper accounts.
#
# Same architecture as every other Mac app here: this container serves plain
# HTTP on the LAN only, and the Caddy reverse proxy on the Pi terminates HTTPS
# and forwards to MAC_IP:3500 (see ../pi-reverse-proxy/).
#
# ── Why BOTH a LAN gate and its own login ────────────────────────────────
# The Caddy gate admits anything on the LAN or the tailnet, which is the right
# availability — family on the tailnet may be given accounts later — but it is
# not an identity: it cannot tell one tailnet device from another, and these
# are personal drafts. HedgeDoc's own accounts supply the identity, and notes
# default to PRIVATE (owner-only).
#
# ── Why HedgeDoc and not Etherpad ────────────────────────────────────────
# An Etherpad instance was built for this and torn down the same day. Its
# `requireAuthentication` is HTTP Basic only — a native browser prompt with
# browser-cached credentials and no usable logout — it has no per-note
# permissions at all, and its built-in OIDC provider cannot serve pad login
# (core only accepts Basic, and the provider's own endpoints sit behind that
# same gate). None of it was fixable by configuration.
#
# Everything claimed below was verified by running it behind a Caddy carrying
# this repo's real security_headers, not inferred:
# * renders correctly under X-Frame-Options: DENY (no iframe editor, unlike
# Etherpad, so no per-site header override is needed)
# * real login form; with CMD_ALLOW_EMAIL_REGISTER=false the register option
# disappears entirely
# * real logout that actually ends the session
# * a note left PRIVATE returns 403 to a *different signed-in account* and
# does not appear in that account's History
# * the /socket.io/ upgrade works through Caddy untouched — typed content was
# confirmed present in Postgres, not just in the browser
#
# ── The trap: HedgeDoc builds ABSOLUTE URLs ──────────────────────────────
# Every asset URL is constructed from CMD_DOMAIN + CMD_PROTOCOL_USESSL +
# CMD_URL_ADDPORT, NOT from the incoming request. Set them to the container's
# own values rather than the public ones and you get a completely unstyled page
# with every asset 404ing. Hit exactly that during testing. They must describe
# how a *browser* reaches this, i.e. https://docs.mathewcsims.uk with no port.
#
# Secrets live in Proton Pass, not a .env file (see ./.env.example). Deploy
# with ./scripts/pass-deploy.sh docs — never a bare `podman compose up -d`, or
# POSTGRES_PASSWORD resolves to empty and Postgres silently initialises with
# trust auth.
name: docs

services:
postgres:
# 17-alpine. Pinned by digest, not the tag alone — tags aren't immutable,
# so a bare version tag can still silently move. Same pattern as
# ../healthlog/compose.yaml, this repo's other Postgres app.
image: postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: docs-postgres
restart: unless-stopped
security_opt:
- no-new-privileges:true
environment:
POSTGRES_USER: hedgedoc
POSTGRES_DB: hedgedoc
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./pgdata:/var/lib/postgresql/data
# No `ports:` — reached only by the hedgedoc service, by container name,
# over this project's own default network. Never exposed to the LAN.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hedgedoc"]
interval: 5s
timeout: 5s
retries: 5
mem_limit: 512m
pids_limit: 256

hedgedoc:
image: quay.io/hedgedoc/hedgedoc@sha256:14f583bcf248e407a8b95e1c4bbf612543d13d3ad6b103aab8f007a99db5dd17 # 1.11.1
container_name: docs
restart: unless-stopped
# Do NOT set `user:` — on podman-machine (macOS) the container's root maps
# to your host user, giving sane ownership on the bind mounts below. Same
# reasoning as vikunja and copyparty.
#
# Hardening. The capability set is dictated by the image, not chosen: the
# entrypoint starts as root and drops to the `hedgedoc` user, so a bare
# `cap_drop: ALL` crash-loops it with
# error: failed switching to "hedgedoc": operation not permitted
# — the same class of trap the BookStack block records for s6 images.
#
# SETUID and SETGID are the MINIMAL set, established by probing rather
# than copied from BookStack: with cap_drop ALL it fails; +SETUID,SETGID
# it starts; adding CHOWN, DAC_OVERRIDE and FOWNER changes nothing. Also
# verified with the uploads bind mount attached (the one place a chown
# might have been needed) — it starts and the directory is writable.
# NET_BIND_SERVICE is not needed either; it binds 3000, not a low port.
#
# Limits are ~4x observed steady state — measured 120 MiB for HedgeDoc and
# 30 MiB for Postgres at idle.
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
mem_limit: 512m
pids_limit: 256
depends_on:
postgres:
condition: service_healthy
environment:
TZ: Europe/London
CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@postgres:5432/hedgedoc

# See "The trap" in the header. These describe how a BROWSER reaches
# this instance, not how the container is addressed internally.
CMD_DOMAIN: docs.mathewcsims.uk
CMD_PROTOCOL_USESSL: "true"
CMD_URL_ADDPORT: "false"

# Accounts only. No guests at all: the LAN gate already limits *who can
# reach* this, and identity is what decides what they can see. Combined
# with CMD_ALLOW_FREEURL=false, an unauthenticated visitor cannot read
# or create anything.
CMD_ALLOW_ANONYMOUS: "false"
CMD_ALLOW_FREEURL: "false"

# Email/password login is on, but self-registration is off — accounts
# are created deliberately with ./scripts/docs-add-user.sh, which wraps
# HedgeDoc's own `bin/manage_users`. This is what stops the instance
# growing accounts you didn't intend.
CMD_EMAIL: "true"
CMD_ALLOW_EMAIL_REGISTER: "false"

# New notes are owner-only until deliberately shared. The safe default
# for personal drafts, and verified: a private note 403s for another
# signed-in account and stays out of its History.
CMD_DEFAULT_PERMISSION: private
volumes:
# Images pasted into notes land here. This is user data with no copy in
# git, so Kopia is its only backup — see kopia-mac/backup.sh.
- ./uploads:/hedgedoc/public/uploads
ports:
# IMPORTANT (same podman-machine quirk as every other Mac app here): a
# published port must be bound to the Mac's *actual* LAN IP —
# podman-machine cannot bind 0.0.0.0 to the real interface. The Pi's
# reverse proxy connects to this address. 3000 is HedgeDoc's own default
# but karakeep already holds it (the same reason healthlog took 3200),
# so 3500. The router does NOT forward 3500, so this is never directly
# internet-facing even before the Caddy LAN-gate is considered.
- "10.0.1.14:3500:3000"
healthcheck:
# node, NOT wget: this image ships no wget, curl, nc or python3 —
# confirmed by checking inside the running container. A wget healthcheck
# would report the container permanently unhealthy while the service was
# perfectly fine, which is a mistake already made once on the Etherpad
# instance this replaces.
#
# 127.0.0.1, NOT localhost — localhost can resolve to ::1 first while the
# app listens on IPv4, the other half of that same Etherpad mistake.
#
# /status is served without authentication (verified). Caddy blocks it
# from outside; this check never goes through Caddy.
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
Loading