▍ humdrum codex / custard
license AGPL-3.0

feat(deploy): make custard portable + self-host docs

f39063b930be18ae126293a0110f2daed64f6b84
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-17 20:12

parent 62366fb3

feat(deploy): make custard portable + self-host docs

- neutralize kortum-specific defaults (soft-serve-http → localhost)
- deploy.sh now reads deploy/deploy.env and generates the Caddyfile + systemd
  unit from your settings (DOMAIN, REMOTE, RUN_USER, REPOS_PATH, SOFT_SERVE_DB,
  SOFT_SERVE_BACKEND) — nothing hand-edited on the server; deploy.env gitignored
- works without Soft Serve (empty SOFT_SERVE_DB serves all repos; empty backend
  drops the /git clone proxy) — any dir of bare *.git repos
- README rewritten: requirements table, local quick-start, fonts note, full
  config table, one-command deploy, architecture diagram
- deploy/deploy.env.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

7 files changed

.gitignore +3 −0
@@ -15,3 +15,6 @@ .windsurfrules
 .aider*
 .claude/
 .github/copilot-instructions.md
+
+# Local deploy config (server/domain) — never published
+deploy/deploy.env
README.md +83 −19
@@ -1,30 +1,94 @@
 # custard
 
-A custom, public-facing **web code forge** over a self-hosted
-[Soft Serve](https://github.com/charmbracelet/soft-serve) git server. custard reads the
-same bare repos Soft Serve hosts — directly off disk via go-git — and renders its own
-templates. It never writes git; pushing and admin stay in Soft Serve over SSH.
+A self-hostable, public-facing **web code forge** for your own git server. custard reads
+**bare git repositories directly off disk** (via go-git) and renders its own pages — repo
+browser, syntax-highlighted files, commit log + per-file diffs, branches/tags, and a
+GitHub-style **issues** view backed by in-repo [Backlog.md](https://backlog.md) tasks. It is
+**read-only**: it never writes git, so pushing and admin stay in your git server.
 
-The look is the [Bubble Tea / Charm](https://charm.sh) terminal aesthetic layered on the
-_shared-app-kit token system (Flexoki / Uchu / Humdrum themes, Awke / Untitled Sans /
-Name Mono type).
+The look is the [Bubble Tea / Charm](https://charm.sh) terminal aesthetic on a CSS-token
+theme system — 7 themes (Flexoki / Uchu / Humdrum, each light + dark, plus e-ink).
 
-## Features
+It pairs naturally with [Soft Serve](https://github.com/charmbracelet/soft-serve) (it can read
+Soft Serve's database to serve only public repos), but it works with **any directory of bare
+`*.git` repos**.
+
+## What you need
 
-- Repo list, tree, blob (syntax-highlighted), raw, commit log, commit diffs, branches & tags
-- **Issues** — renders each repo's in-repo `backlog/tasks/*.md` ([Backlog.md](https://backlog.md))
-  GitHub-style, grouped by the repo's own configured statuses; first label = type badge
-- 7 themes via a cookie-backed switcher (flexoki / uchu / humdrum, light + dark, plus e-ink),
-  flash-free server-side render
-- Syntax + diff highlighting (chroma), Markdown (goldmark), all colored through theme tokens
+| | Required? | For |
+|---|---|---|
+| **Go 1.26+** | to build | compiling the single static binary |
+| A dir of bare `*.git` repos | **yes** | the content custard serves |
+| [Soft Serve](https://github.com/charmbracelet/soft-serve) | optional | private/hidden gating + HTTPS clone proxy |
+| [Backlog.md](https://backlog.md) tasks in a repo | optional | the per-repo Issues tab |
+| A server + domain + [Caddy](https://caddyserver.com) | to go public | TLS + reverse proxy |
 
-## Develop
+## Quick start (local)
 
 ```bash
-go install github.com/a-h/templ/cmd/templ@latest   # one-time
-templ generate                                      # after editing *.templ
-go run ./cmd/custard --repos ./repos --addr :8080
-go test ./...
+go install github.com/a-h/templ/cmd/templ@latest   # one-time: the template generator
+templ generate                                      # after editing any *.templ
+go run ./cmd/custard --repos /path/to/bare/repos --addr :8080
+# open http://localhost:8080
+```
+
+`--repos` is any directory containing `name.git` bare repositories. With no other flags,
+custard lists **every** repo it finds (fine for a fully public/local setup).
+
+### Fonts
+
+The Charm look uses three commercial faces that are **not** bundled (licensing). Without them
+custard falls back to your system mono/sans — fully functional, just plainer. To get the
+intended type, drop your own `.woff2` files in `web/static/fonts/` matching the `@font-face`
+names in `web/static/tokens.css` (`Awke`, `Untitled Sans`, `Name Mono`). They're embedded into
+the binary at build time.
+
+## Configuration
+
+All via flags or env vars (flag wins):
+
+| Flag | Env | Default | Purpose |
+|---|---|---|---|
+| `--repos` | `REPOS_PATH` | `./repos` | directory of bare `*.git` repos |
+| `--addr` | `LISTEN_ADDR` | `:8080` | listen address |
+| `--base-url` | `BASE_URL` | `http://localhost:8080` | public base URL |
+| `--soft-serve-http` | `SOFT_SERVE_HTTP` | `http://localhost:23232` | clone URL base shown in the footer |
+| `--soft-serve-db` | `SOFT_SERVE_DB` | _(empty)_ | path to `soft-serve.db`; **when set, only public (non-private, non-hidden) repos are served** |
+
+> **Private repos:** if you point custard at a Soft Serve repos dir, set `--soft-serve-db` so
+> private/hidden repos are hidden from the list **and** 404 on direct access. Without it, every
+> repo on disk is public — only do that if they're all meant to be public.
+
+## Deploy (self-host, public)
+
+One script provisions Caddy (auto-TLS) + a systemd service, generating all config from your
+settings — nothing to hand-edit on the server.
+
+```bash
+# 1. DNS: point your domain's A record at the server.
+# 2. Configure:
+cp deploy/deploy.env.example deploy/deploy.env
+$EDITOR deploy/deploy.env          # REMOTE, DOMAIN, RUN_USER, REPOS_PATH, ...
+# 3. Deploy (re-run anytime to update):
+deploy/deploy.sh
+```
+
+`deploy.sh` builds a static `linux/amd64` binary, ships it to `REMOTE`, writes
+`/etc/systemd/system/custard.service` (custard runs as `RUN_USER`, bound to `127.0.0.1:8080`,
+repos mounted read-only) and `/etc/caddy/Caddyfile` (TLS for `DOMAIN`; `/git/*` → your Soft
+Serve clone backend; `/dl/*` for release tarballs), then starts both. See `deploy/deploy.env.example`
+for every setting.
+
+**Not using Soft Serve?** Leave `SOFT_SERVE_DB` empty (serves all repos) and `SOFT_SERVE_BACKEND`
+empty (drops the `/git` clone proxy). custard still serves any bare repos in `REPOS_PATH`.
+
+## How it fits together
+
+```
+            HTTPS                 read-only, on disk
+browser ──▶ Caddy ──▶ custard ──▶ /path/to/repos/*.git   (go-git)
+              │                └─▶ soft-serve.db          (visibility, optional)
+              └─/git/*─▶ Soft Serve HTTP (clone, optional)
 ```
 
 See `PLAN.md` for the phased build plan.
deploy/Caddyfile +0 −20
@@ -1,20 +0,0 @@
-# custard — Caddy reverse proxy with automatic TLS.
-# Web UI on 443; /git/* proxied to Soft Serve's HTTP backend for read-only clone.
-git.kortum.world {
-	encode gzip zstd
-
-	# Read-only HTTPS git clone → Soft Serve HTTP (handle_path strips /git):
-	#   git clone https://git.kortum.world/git/<repo>.git
-	handle_path /git/* {
-		reverse_proxy 127.0.0.1:23232
-	}
-
-	# Release tarballs (populated by the phase-4 Homebrew pipeline).
-	handle_path /dl/* {
-		root * /var/lib/custard/dl
-		file_server browse
-	}
-
-	# Everything else → the custard web app.
-	reverse_proxy 127.0.0.1:8080
-}
deploy/custard.service +0 −28
@@ -1,28 +0,0 @@
-[Unit]
-Description=custard — web code forge over Soft Serve
-After=network-online.target soft-serve.service
-Wants=network-online.target
-
-[Service]
-# Runs as the soft-serve user so it can read the bare repos (read-only).
-User=soft-serve
-Group=soft-serve
-ExecStart=/usr/local/bin/custard \
-  --repos /var/lib/soft-serve/repos \
-  --addr 127.0.0.1:8080 \
-  --base-url https://git.kortum.world \
-  --soft-serve-http https://git.kortum.world/git \
-  --soft-serve-db /var/lib/soft-serve/soft-serve.db
-Restart=on-failure
-RestartSec=2
-
-# Hardening. ProtectSystem=full (not strict) keeps /var writable so SQLite can
-# manage the db's -wal/-shm sidecars; the repos tree stays read-only.
-NoNewPrivileges=true
-ProtectSystem=full
-ProtectHome=true
-PrivateTmp=true
-ReadOnlyPaths=/var/lib/soft-serve/repos
-
-[Install]
-WantedBy=multi-user.target
deploy/deploy.env.example +23 −0
@@ -0,0 +1,23 @@
+# custard deploy config. Copy to deploy/deploy.env and edit. deploy.env is gitignored.
+# Then run:  deploy/deploy.sh
+
+# SSH target of your server (must have root or sudo).
+REMOTE=root@your-server
+
+# Public hostname for the forge (DNS A record must point at the server).
+DOMAIN=git.example.com
+
+# Unix user that owns the bare repos and runs custard (read-only).
+# For Soft Serve this is usually "soft-serve".
+RUN_USER=soft-serve
+
+# Directory of bare *.git repositories custard reads.
+REPOS_PATH=/var/lib/soft-serve/repos
+
+# Soft Serve database — when set, ONLY public (non-private, non-hidden) repos are
+# served. Leave EMPTY to serve every repo in REPOS_PATH (no Soft Serve needed).
+SOFT_SERVE_DB=/var/lib/soft-serve/soft-serve.db
+
+# host:port of the Soft Serve HTTP clone backend, proxied at https://DOMAIN/git
+# Leave EMPTY to omit the clone proxy entirely (e.g. if you don't use Soft Serve).
+SOFT_SERVE_BACKEND=127.0.0.1:23232
deploy/deploy.sh +76 −31
@@ -1,54 +1,99 @@
 #!/usr/bin/env bash
 #
-# deploy.sh — build custard for linux/amd64 and deploy it to the droplet behind
-# Caddy + systemd, next to Soft Serve. Idempotent; safe to re-run for updates.
-#
-# Usage: deploy/deploy.sh <user@host>   (or set CUSTARD_DEPLOY_REMOTE)
+# deploy.sh — build custard for linux/amd64 and deploy it behind Caddy + systemd.
+# Reads deploy/deploy.env (copy from deploy.env.example). Idempotent; re-run to
+# update. Generates the Caddyfile + systemd unit from your settings — nothing to
+# hand-edit on the server.
 #
 set -euo pipefail
+cd "$(dirname "$0")/.."
 
-REMOTE="${1:-${CUSTARD_DEPLOY_REMOTE:-}}"
-if [ -z "$REMOTE" ]; then
-	echo "usage: deploy/deploy.sh <user@host>   (or set CUSTARD_DEPLOY_REMOTE)" >&2
+ENV_FILE="deploy/deploy.env"
+if [ ! -f "$ENV_FILE" ]; then
+	echo "missing $ENV_FILE — copy deploy/deploy.env.example to it and fill in your values" >&2
 	exit 1
 fi
-REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$REPO_ROOT"
+set -a; . "$ENV_FILE"; set +a
+: "${REMOTE:?set REMOTE in deploy.env}"
+: "${DOMAIN:?set DOMAIN in deploy.env}"
+: "${RUN_USER:?set RUN_USER in deploy.env}"
+: "${REPOS_PATH:?set REPOS_PATH in deploy.env}"
+SOFT_SERVE_DB="${SOFT_SERVE_DB:-}"
+SOFT_SERVE_BACKEND="${SOFT_SERVE_BACKEND:-}"
 
-echo "==> generating templates + building linux/amd64 binary"
-templ generate
+echo "==> building static linux/amd64 binary"
+command -v templ >/dev/null 2>&1 && templ generate
 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/custard-linux ./cmd/custard
 
-echo "==> uploading binary + unit + Caddyfile to $REMOTE"
-scp /tmp/custard-linux "$REMOTE:/usr/local/bin/custard.new"
-scp deploy/custard.service "$REMOTE:/etc/systemd/system/custard.service"
-scp deploy/Caddyfile "$REMOTE:/etc/caddy/Caddyfile.custard"
+echo "==> rendering Caddyfile + unit for $DOMAIN"
+gitblock=""
+if [ -n "$SOFT_SERVE_BACKEND" ]; then
+	gitblock="  handle_path /git/* {
+    reverse_proxy $SOFT_SERVE_BACKEND
+  }
+"
+fi
+cat > /tmp/custard.Caddyfile <<EOF
+# Generated by custard deploy.sh — do not edit by hand.
+${DOMAIN} {
+  encode gzip zstd
+${gitblock}  handle_path /dl/* {
+    root * /var/lib/custard/dl
+    file_server browse
+  }
+  reverse_proxy 127.0.0.1:8080
+}
+EOF
+
+dbflag=""
+[ -n "$SOFT_SERVE_DB" ] && dbflag=" --soft-serve-db ${SOFT_SERVE_DB}"
+cat > /tmp/custard.service <<EOF
+[Unit]
+Description=custard — web code forge
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+User=${RUN_USER}
+Group=${RUN_USER}
+ExecStart=/usr/local/bin/custard --repos ${REPOS_PATH} --addr 127.0.0.1:8080 --base-url https://${DOMAIN} --soft-serve-http https://${DOMAIN}/git${dbflag}
+Restart=on-failure
+RestartSec=2
+NoNewPrivileges=true
+ProtectSystem=full
+ProtectHome=true
+PrivateTmp=true
+ReadOnlyPaths=${REPOS_PATH}
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+echo "==> uploading to $REMOTE"
+scp -q /tmp/custard-linux "$REMOTE:/usr/local/bin/custard.new"
+scp -q /tmp/custard.service "$REMOTE:/etc/systemd/system/custard.service"
+scp -q /tmp/custard.Caddyfile "$REMOTE:/tmp/custard.Caddyfile"
 
-echo "==> installing/configuring on remote"
+echo "==> installing on remote"
 ssh "$REMOTE" 'bash -seu' <<'REMOTE_EOF'
-# Install Caddy if missing (official apt repo).
 if ! command -v caddy >/dev/null 2>&1; then
-  apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
+  echo "installing caddy..."
+  apt-get install -y -q debian-keyring debian-archive-keyring apt-transport-https curl >/dev/null 2>&1
   curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
   curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
-  apt-get update && apt-get install -y caddy
+  apt-get update -q >/dev/null 2>&1 && apt-get install -y -q caddy >/dev/null 2>&1
 fi
-
-# Use our Caddyfile.
-mv /etc/caddy/Caddyfile.custard /etc/caddy/Caddyfile
-
-# Swap binary atomically + restart.
+install -d -o caddy -g caddy /etc/caddy /var/lib/custard/dl
+mv /tmp/custard.Caddyfile /etc/caddy/Caddyfile
 mv /usr/local/bin/custard.new /usr/local/bin/custard
 chmod +x /usr/local/bin/custard
-
 systemctl daemon-reload
-systemctl enable --now custard
+systemctl enable --now custard >/dev/null 2>&1 || true
 systemctl restart custard
-systemctl reload caddy || systemctl restart caddy
-
+caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null && { systemctl reload caddy || systemctl restart caddy; }
 sleep 1
-echo "--- custard ---"; systemctl --no-pager --lines=5 status custard | tail -6
-echo "--- local probe ---"; curl -s -o /dev/null -w 'custard 127.0.0.1:8080 -> %{http_code}\n' http://127.0.0.1:8080/ || true
+echo "  custard: $(systemctl is-active custard)   caddy: $(systemctl is-active caddy)"
+curl -s -o /dev/null -w '  local probe -> %{http_code}\n' http://127.0.0.1:8080/ || true
 REMOTE_EOF
 
-echo "==> done. https://git.kortum.world"
+echo "==> done → https://${DOMAIN}"
internal/config/config.go +1 −1
@@ -21,7 +21,7 @@ 	var c Config
 	flag.StringVar(&c.ReposPath, "repos", env("REPOS_PATH", "./repos"), "directory of bare *.git repositories")
 	flag.StringVar(&c.ListenAddr, "addr", env("LISTEN_ADDR", ":8080"), "listen address")
 	flag.StringVar(&c.BaseURL, "base-url", env("BASE_URL", "http://localhost:8080"), "public base URL")
-	flag.StringVar(&c.SoftServeHTTP, "soft-serve-http", env("SOFT_SERVE_HTTP", "http://git.kortum.world:23232"), "Soft Serve HTTP clone base")
+	flag.StringVar(&c.SoftServeHTTP, "soft-serve-http", env("SOFT_SERVE_HTTP", "http://localhost:23232"), "git clone base shown in the UI footer (e.g. https://your-host/git)")
 	flag.StringVar(&c.SoftServeDB, "soft-serve-db", env("SOFT_SERVE_DB", ""), "path to soft-serve.db; when set, only public (non-private, non-hidden) repos are served")
 	flag.Parse()
 	return c