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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
.env
**/.env
copyparty/cfg/accounts.conf
# The scoped Super Productivity sync account, rendered from its own Pass
# item ("Copyparty SP Sync") at deploy time — same treatment as
# accounts.conf above, kept in a separate file because the agent Pass token
# can create items but not update them.
copyparty/cfg/accounts-spsync.conf
# local-only volume definitions and compose overrides — extra copyparty
# areas that deliberately stay out of the public repo entirely
copyparty/cfg/local-*.conf
Expand All @@ -28,6 +33,11 @@ kopia-mac/.mirror-state
copyparty/data/
copyparty/public/
copyparty/inbox/
# Super Productivity's synced task data — the ONLY copy besides whatever is
# in each browser's IndexedDB, so it is a Kopia source (see kopia-mac/backup.sh)
copyparty/sp-sync/
# Fizzy's SQLite databases and Active Storage uploads — its entire state
fizzy/storage/
# copyparty's own internal state: salts, sessions.db, shares.db, cert.pem, iphash
copyparty/cfg/copyparty/
memos-prospect-ukri-tus/data/
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ in front of the lot.
| [Ghost](https://ghost.org) | `blog.mathewcsims.uk` | Mac (replaces paid Ghost(Pro) hosting) |
| [LittleLink](https://github.com/sethcottle/littlelink) | `mathewcsims.uk` | Mac (bare apex domain — static, no backend) |
| [Karakeep](https://github.com/karakeep-app/karakeep) | `karakeep.mathewcsims.uk` | Mac (migrated from a separate Tailscale-only deployment) |
| [Fizzy](https://www.fizzy.do) | `fizzy.mathewcsims.uk` | Mac (LAN-only — backlog / triage board) |
| [Super Productivity](https://super-productivity.com) | `sp.mathewcsims.uk` | Mac (LAN-only — day-to-day tasks; syncs to copyparty at `/sp-sync`) |
| [Apprise API](https://github.com/caronc/apprise-api) | `apprise.mathewcsims.uk` | Pi (LAN-only — generic notification relay to Discord) |
| [Uptime Kuma](https://github.com/louislam/uptime-kuma) | `status.mathewcsims.uk` | Pi (deliberately — stays up if the Mac doesn't) |
| Tailscale webhook relay (this repo) | `tailscale-relay.mathewcsims.uk` | Pi (public — bridges Tailscale's webhook events to Apprise; HMAC-verified) |
Expand Down Expand Up @@ -78,6 +80,8 @@ internet → DrayTek router → Pi (Caddy, terminates HTTPS, routes by hostname)
├─ prospect-ukri-tus.mathewcsims.uk → Mac
├─ blog.mathewcsims.uk → Mac
├─ karakeep.mathewcsims.uk → Mac
├─ fizzy.mathewcsims.uk → Mac (LAN clients only)
├─ sp.mathewcsims.uk → Mac (LAN clients only)
├─ apprise.mathewcsims.uk → itself (Pi, LAN clients only)
├─ status.mathewcsims.uk → itself (Pi)
├─ backup.mathewcsims.uk → itself (Pi, LAN clients only)
Expand Down Expand Up @@ -200,6 +204,9 @@ memos-prospect-ukri-tus/ compose.yaml and data (Mac)
blog/ compose.yaml, MySQL, and Ghost content (Mac)
landing-page/ compose.yaml, static site content (Mac, no secrets)
karakeep/ compose.yaml, bookmark/asset data, search index (Mac)
fizzy/ compose.yaml and data (Mac — Kanban backlog, LAN-only)
super-productivity/ compose.yaml only (Mac — static app, holds NO data;
tasks live in the browser + copyparty's /sp-sync)
apprise/ compose.yaml (Pi — deployed via scp + docker compose, LAN-only)
uptime-kuma/ compose.yaml (Pi — deployed via scp + docker compose)
kopia-server/ compose.yaml + Dockerfile + entrypoint.sh (Pi — deployed via scp + docker compose, LAN-only)
Expand Down
305 changes: 296 additions & 9 deletions SETUP.md

Large diffs are not rendered by default.

63 changes: 59 additions & 4 deletions copyparty/cfg/copyparty.conf
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,22 @@
# /orgtasks volume decommissioned, nothing here speaks WebDAV again, so the
# incompatibility is moot and the ambiguity is free.
#
# If a WebDAV volume is ever added back, THIS IS THE FIRST THING TO REMOVE,
# and test an UNAUTHENTICATED GET returns 401 — not merely that an
# authenticated one returns 200. `curl -u` and most JS clients send Basic
# auth preemptively and will pass while a challenge-based client is broken.
# A WebDAV volume WAS added back on 2026-08-08 (/sp-sync, below) and this
# was deliberately KEPT. The instruction that used to sit here — "remove
# this first" — assumed any WebDAV client waits to be challenged, which is
# what broke Orgzly. Both halves were checked this time rather than
# assumed:
#
# * Super Productivity's WebDAV client sets `Authorization: Basic` on
# EVERY request (read in its source, not inferred), so it never depends
# on being challenged;
# * and an UNAUTHENTICATED PROPFIND against a vague-403 instance returns
# **401**, not 404 — measured in a throwaway container with this exact
# config, which is the assertion the old note demanded.
#
# If a CHALLENGE-BASED client is ever added, re-run that second test first:
# an authenticated 200 proves nothing, because `curl -u` and every JS
# client send Basic auth preemptively and pass regardless.
vague-403

# Brute-force login lockout: more than 9 wrong passwords in 60min bans that
Expand Down Expand Up @@ -85,3 +97,46 @@
A: admin # only admin (you) can see and retrieve what's been dropped.
# No "r" for anyone but admin, and no anonymous access, so a visitor must
# know the inbox password to upload, and can never browse the contents.

# ── Super Productivity's WebDAV sync target ────────────────────────────────
# Holds nothing but Super Productivity's sync blob. The app is at
# https://sp.mathewcsims.uk and reaches this volume at
# https://sp.mathewcsims.uk/sp-sync/ — the SAME ORIGIN, via a Caddy route
# that proxies that path here without rewriting it. That is deliberate:
# Super Productivity syncs from the BROWSER, so a cross-origin target would
# need CORS, and copyparty only exposes --acao/--acam with no control over
# Access-Control-Allow-Headers. SP sends Depth and If-Match, which are not
# CORS-safelisted, so preflight would fail. Same-origin removes the problem
# rather than configuring around it. The path must NOT be rewritten, because
# copyparty puts its own paths in PROPFIND <D:href> and the client resolves
# against them.
#
# The "spsync" account is defined in accounts-spsync.conf, rendered from its
# own Proton Pass item ("Copyparty SP Sync"). It is separate from
# accounts.conf because the agent's Pass token cannot UPDATE items, only
# create them — copyparty merges [accounts] across auto-loaded *.conf files,
# verified in a throwaway container before relying on it.
[/sp-sync]
/sp-sync
accs:
rwmd: spsync
# Read, write, move, delete — all four are required. Delete is not
# optional decoration: copyparty mandates the delete-permission whenever
# "daw" is set, and Super Productivity issues real DELETEs.
A: admin
# Admin keeps full access so the sync blob is reachable and restorable
# from the normal copyparty UI without the scoped account.
flags:
daw
# VOLFLAG, NOT GLOBAL — this is the important part. Without "daw",
# copyparty answers a PUT over an existing file by inventing a new
# filename rather than overwriting, which for a sync client means every
# save silently becomes a new file and sync never converges. Set
# globally it would change overwrite semantics for the ENTIRE file
# server, including your actual documents; copyparty's own help says to
# prefer the volflag, and it is scoped to this volume alone.
#
# Needed here specifically because Super Productivity does not send the
# 'x-oc-mtime' header that would otherwise make it unnecessary —
# confirmed by reading its WebDAV client, which sends only GET, PUT,
# DELETE, PROPFIND and MKCOL.
8 changes: 8 additions & 0 deletions copyparty/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ services:
- ./data:/w # PRIVATE storage (admin-only, served at /)
- ./public:/pub # PUBLIC storage (anonymous read, served at /pub)
- ./inbox:/inbox # DROP BOX storage (password write-only, served at /inbox)
# Super Productivity's WebDAV sync target (served at /sp-sync). THIS
# BIND IS LOAD-BEARING: a [/sp-sync] volume in copyparty.conf with no
# matching mount here still "works" — copyparty logs
# `type=overlay` and writes into the container's ephemeral layer, so
# every task would be lost the next time the container is recreated,
# silently and with no error. Caught during setup by reading that log
# line rather than trusting that the volume block was enough.
- ./sp-sync:/sp-sync
ports:
# IMPORTANT: podman-machine on macOS will only expose a published port on
# the *specific* host IP you name here — it cannot bind 0.0.0.0 to the real
Expand Down
8 changes: 8 additions & 0 deletions fizzy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Template only — the real .env is fetched live from Proton Pass at deploy
# time via ../scripts/pass-deploy.sh, never committed here. Create the item
# once with ../scripts/pass-create-fizzy-secrets.sh.
#
# Rails derives every other secret from this one (signed/encrypted cookies,
# Active Record encryption, secure link tokens), so rotating it invalidates
# all sessions. Treat it as permanent.
SECRET_KEY_BASE=changeme
146 changes: 146 additions & 0 deletions fizzy/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Fizzy (https://www.fizzy.do) — 37signals' open-source Kanban board, used
# here as the BACKLOG: someday / now / done / never triage, not detailed task
# work. Detailed day-to-day tasks live in ../super-productivity/, recurring
# chores live in Tasks.org on the phone. See SETUP.md's "Task management"
# section for why the split is three tools and not one.
#
# Same architecture as ../copyparty: TLS and the public hostname
# (fizzy.mathewcsims.uk) are handled by Caddy on the Raspberry Pi (see
# ../pi-reverse-proxy/). This Mac only serves plain HTTP on the LAN, and only
# to the Pi — see ../pf-lockdown/, which restricts port 3600 the same way it
# restricts copyparty's 3923.
#
# LAN/tailnet-gated at Caddy, not public. It holds a private backlog, and
# Tailscale already covers access from away — the same call as docs,
# paperless, author and fj.
#
# SQLite, no DB sidecar: Fizzy's own Docker deployment guide puts everything
# under /rails/storage and ships no database service. Same discipline as
# Forgejo and Memos.
#
# All secrets live in ./.env (gitignored — see .env.example), rendered from
# Proton Pass at deploy time by ../scripts/pass-deploy.sh. NEVER run a bare
# `podman compose up -d` here: SECRET_KEY_BASE would silently become blank,
# and Rails would generate a new one, invalidating every session and any
# encrypted column.
name: fizzy

services:
fizzy:
# Pinned by digest. Upstream publishes a rolling `:main` tag rather than
# versioned releases, so the tag alone would move under us silently —
# digest-pinning makes every upgrade a reviewed decision, same as every
# other image in this repo. Verified arm64-native, so no emulation on the
# M4 (unlike the organice attempt, which needed linux/amd64).
image: ghcr.io/basecamp/fizzy@sha256:c0aadd02a8249752998d3fe930ce99e24de6997522511ee052d1bb44616ac521
container_name: fizzy
restart: unless-stopped
# Do NOT set `user:` — on podman-machine (macOS) the container's root maps
# to the host user, giving sane ownership on the bind mount below. Same
# reasoning as copyparty's and Ghost's compose.yaml.
environment:
SECRET_KEY_BASE: ${SECRET_KEY_BASE}

# Used to build absolute links (emails, push payloads, secure links).
BASE_URL: https://fizzy.mathewcsims.uk

# The image runs as uid 1000 (verified: `user=1000:1000`, entrypoint
# `./bin/thrust ./bin/rails server`), so the Thruster front-end CANNOT
# bind its default port 80 — it crash-loops with
# "listen tcp :80: bind: permission denied". Upstream's own example
# compose publishes 80/443 and works only because plain Docker there
# grants the capability; it does not here.
#
# Moving Thruster to an unprivileged port is the right fix. The
# alternative — running the container as root to reclaim port 80 —
# would undo the image's own privilege separation for no benefit,
# since Caddy is what the outside world talks to anyway.
HTTP_PORT: "8080"

# DELIBERATELY NOT SET: TLS_DOMAIN and DISABLE_SSL.
#
# TLS_DOMAIN would make Fizzy terminate its own TLS, which is Caddy's
# job here. DISABLE_SSL would switch off `config.assume_ssl` AND
# `config.force_ssl` together (config/environments/production.rb:76-83
# derives both from it), losing HSTS and secure cookies.
#
# Leaving both unset is exactly right behind a terminating proxy:
# assume_ssl=true makes Rails treat the forwarded request as HTTPS —
# so force_ssl does not bounce it into a redirect loop — while
# force_ssl=true keeps secure cookies and HSTS. This is Rails' own
# documented pattern for this topology, not a workaround.

# Single-account mode is the DEFAULT and is left alone: Fizzy closes
# signups automatically as soon as the first account exists. That is
# the registration hardening every other app in this repo does
# explicitly (Vikunja's ENABLEREGISTRATION=false, Forgejo's
# DISABLE_REGISTRATION, Karakeep's DISABLE_SIGNUPS) — here it is
# upstream's default, so MULTI_TENANT must simply never be set to true.

# SMTP — Proton submission. This is the FIRST app in this repo with
# working outbound mail; every other one leaves it commented out.
#
# Fizzy needs it for real: sign-in is a magic link / 6-character code,
# so without mail the only way in is reading the code out of this
# container's log. That is fine for a one-off and unacceptable as the
# standing arrangement.
#
# Credentials come from the SHARED "Proton SMTP" Pass item — created
# deliberately as a general-purpose sender for this and future apps,
# not per-app — so Fizzy must be deployed with BOTH items:
# ./scripts/pass-deploy.sh fizzy Fizzy "Proton SMTP"
# Deploying with only "Fizzy" leaves the credentials blank and every
# send fails authentication.
#
# THE FIELD NAMES DO NOT LINE UP, hence the mapping below: the Pass
# item uses SMTP_SERVER/SMTP_TOKEN (generic, provider-shaped) while
# Fizzy wants SMTP_ADDRESS/SMTP_PASSWORD (Rails ActionMailer-shaped).
# Mapping here rather than renaming the Pass fields keeps that item
# reusable by apps with their own naming.
#
# SMTP_TLS is deliberately unset: Proton's submission endpoint is
# STARTTLS on 587, and Fizzy's own docs say STARTTLS is used
# automatically — SMTP_TLS is only for implicit-TLS servers on 465.
#
# MAILER_FROM_ADDRESS must MATCH the address the Proton token was
# issued against (self-hosted@mathewcsims.uk). Proton rejects a From
# that differs from the token's own address, so this is not
# independently configurable — note it is the SENDING address, and is
# deliberately not the same as the account owner's address.
SMTP_ADDRESS: ${SMTP_SERVER}
SMTP_PORT: ${SMTP_PORT}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_TOKEN}
MAILER_FROM_ADDRESS: ${SMTP_USERNAME}

# Web Push is unavailable without VAPID keys, which cannot be generated
# before first boot (they need a Rails console in the running
# container). Not needed: Fizzy's notifications are activity-based
# (comments, assignment), not the due-date reminders this stack relies
# on — those come from Tasks.org and Super Productivity. To add later:
# podman exec -it fizzy bin/rails runner 'k=WebPush.generate_key; puts k.private_key, k.public_key'
# then set VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY here and in Pass.

TZ: Europe/London
volumes:
# Everything Fizzy persists — the SQLite databases and Active Storage
# uploads — lives under this one path, per its deployment guide.
- ./storage:/rails/storage
ports:
# IMPORTANT (same podman-machine quirk as every other Mac app): 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
# Caddy connects here. The router does NOT forward 3600, and
# ../pf-lockdown/ additionally blocks every LAN source except the Pi.
- "10.0.1.14:3600:8080"
healthcheck:
# Rails' built-in health endpoint (config/routes.rb: get "up", to:
# "rails/health#show"). `curl` is present in this image and `wget` is
# NOT — checked inside the image rather than assumed, because that
# exact assumption broke a previous deployment here.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8080/up"]
interval: 30s
timeout: 5s
retries: 3
# Rails boots slower than the static apps here; migrations run first.
start_period: 60s
7 changes: 7 additions & 0 deletions kopia-mac/backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ $REPO_ROOT/copyparty/data
$REPO_ROOT/copyparty/public
$REPO_ROOT/copyparty/inbox
$REPO_ROOT/copyparty/cfg/accounts.conf
# Super Productivity's synced tasks. This is the ONLY durable copy — the app
# is local-first, so everything else lives in each browser's IndexedDB, which
# a cleared cache or a reinstalled laptop wipes without warning. The
# super-productivity container itself holds nothing and is not backed up.
$REPO_ROOT/copyparty/sp-sync
# Fizzy's SQLite databases and Active Storage uploads — its entire state.
$REPO_ROOT/fizzy/storage
$REPO_ROOT/owl/data
$REPO_ROOT/docs/pgdata
$REPO_ROOT/docs/uploads
Expand Down
Loading