Rebinding vllm-chat was the expensive option: Petal, Gogobee and Open WebUI all point at 127.0.0.1:8000, and Open WebUI keeps its endpoint in its own database rather than in env, so moving the bind address meant editing three consumers and reloading a 35B AWQ model. The socat unit adds a second listener on the headscale address instead -- local callers untouched, no downtime, and the only new exposure is on the VPN. Bound to 100.64.0.2 specifically, never 0.0.0.0: the far end is a public host. Verified: a grammar checkpoint from petal.parodia.dev returns real suggestions in ~3s over the VPN. Also documents two things found on millenia that were invisible from outside it: - Piper had been dead since the Jul 26 reboot, 26,800+ failed restarts, with read-aloud silently falling back to browser Web Speech. An OS upgrade moved /usr/bin/python3 from 3.13 to 3.14, and venv/bin/python3 is a symlink to the system interpreter, so lib/python3.13/site-packages went invisible -- sys.path had no site-packages at all. Recreating the venv lands Piper 1.6.0, which is what TTS_PATH exists for. - The canonical instance has no automated backup (newest snapshot a month old) and runs unsupervised with PPID 1. Both written down; the backup one is pending the encryption-at-rest decision. Backups on the VPS now ride parodia-backup (age-encrypted, offsite, S3), using VACUUM INTO rather than that script's iterdump helper -- iterdump does not reproduce an FTS5 virtual table, so a restore would have come back with cross-document search silently missing.
300 lines
12 KiB
Markdown
300 lines
12 KiB
Markdown
# Deploying Petal
|
|
|
|
Two deployments exist right now:
|
|
|
|
| | host | shape | status |
|
|
|---|---|---|---|
|
|
| **millenia** | `192.168.1.212` / `100.64.0.2` | bare binary in a screen session on `:8088`, Piper as user systemd units | **canonical** — her real writing lives here |
|
|
| **parodia** | `petal.parodia.dev` / `100.64.0.1` | docker compose behind the host's Traefik | staging; empty database |
|
|
|
|
millenia stays canonical until Phase 16's auth lands, so she only moves accounts
|
|
once. Everything below is the VPS side; the millenia Piper notes are kept in the
|
|
appendix because that instance still runs them.
|
|
|
|
---
|
|
|
|
## 1. The VPS stack
|
|
|
|
`docker-compose.yml` at the repo root brings up three containers:
|
|
|
|
- **petal** — the single Go binary with the frontend embedded. Publishes no host
|
|
port; Traefik is the only way in.
|
|
- **piper-en** / **piper-zh** — read-aloud. Each Piper HTTP server loads exactly
|
|
one voice, so English and Chinese are separate containers off one image, with
|
|
the models cached in a shared volume. They sit on an internal network with no
|
|
published ports, so only Petal can reach them. Adding pt-PT in Phase 21 is a
|
|
fourth service, not a new image.
|
|
|
|
They run as containers rather than the host systemd units millenia uses because
|
|
Piper was never actually installed on the VPS, and the `reala` account has no
|
|
lingering session to keep user units alive across logout.
|
|
|
|
### Prerequisites on the host
|
|
|
|
- Docker with the compose plugin, and the existing external `traefik` network
|
|
- A DNS A record for the hostname pointing at the VPS (`petal.parodia.dev` is
|
|
already in place)
|
|
|
|
### First deploy
|
|
|
|
```bash
|
|
ssh reala@100.64.0.1
|
|
git clone https://gitea.parodia.dev/drwily/petal.git ~/petal
|
|
cd ~/petal
|
|
cp deploy/petal.env.example .env
|
|
```
|
|
|
|
Then edit `.env`:
|
|
|
|
- `PETAL_UID` / `PETAL_GID` — `id -u` / `id -g` for this account. `./data` is a
|
|
bind mount, so the image's own `petal` user has no claim on it; a mismatch
|
|
shows up as `unable to open database file (14)` and a restart loop.
|
|
- `PETAL_BASIC_AUTH` — the interim edge gate, see §4.
|
|
- `LLM_MODEL` / `LLM_CHAT_MODEL` — see §3.
|
|
|
|
```bash
|
|
mkdir -p data/backups
|
|
docker compose up -d --build
|
|
docker compose ps # all three healthy
|
|
```
|
|
|
|
### Updating
|
|
|
|
```bash
|
|
cd ~/petal && git pull && docker compose up -d --build
|
|
```
|
|
|
|
The frontend is embedded in the binary, so a rebuild is the whole deploy. The
|
|
client polls `/api/version` (a hash of the built `index.html`) and offers a
|
|
refresh when it changes.
|
|
|
|
---
|
|
|
|
## 2. What Traefik does
|
|
|
|
Labels follow the convention the other services on this box use: the external
|
|
`traefik` network, the `web-secure` entrypoint, the `default` cert resolver and
|
|
`compression@file`. Petal adds its own response-header middleware
|
|
(`frame-ancestors 'self'`, HSTS, nosniff, `Referrer-Policy: same-origin`).
|
|
|
|
`/api/health` is deliberately on its own higher-priority router with no
|
|
middleware: a monitoring probe must not need a credential, and the endpoint
|
|
carries no user data.
|
|
|
|
---
|
|
|
|
## 3. The LLM link over headscale
|
|
|
|
The vLLM backend stays on millenia and is reached over headscale
|
|
(`100.64.0.2`). **This is the only cross-VPN dependency**, and by the
|
|
LLM-minimalism principle it never gates essential functionality — spell check,
|
|
gloss, vocabulary garden, search, export and read-aloud all keep working with
|
|
the link down, and the status bar shows the warm
|
|
`🌙 小助手在休息 · Petal's helper is resting · 文字已保存`.
|
|
|
|
`LLM_TIMEOUT` is raised from the local-network default of 30s to **90s**: the
|
|
voice and collocation passes send a whole document, the timeout is a hard
|
|
deadline on the completion call, and a WAN+VPN round trip eats the margin.
|
|
|
|
### How the link is exposed — a forwarder, not a rebind
|
|
|
|
vLLM stays bound to `127.0.0.1:8000`. `vllm-headscale-proxy.service` (a socat
|
|
unit, in this directory) adds a second listener on `100.64.0.2:8000` that
|
|
forwards to it.
|
|
|
|
The plan originally said to rebind vLLM itself. That turned out to be the
|
|
expensive option: `vllm-chat.service` is **shared** — Petal, Gogobee and Open
|
|
WebUI all point at `127.0.0.1:8000`, and Open WebUI stores its endpoint in its
|
|
own database rather than in env — so moving the bind address would mean editing
|
|
three consumers and reloading a 35B AWQ model, minutes of downtime for all of
|
|
them. The forwarder adds a door instead of moving one: local callers are
|
|
untouched, and the only new exposure is on the VPN interface.
|
|
|
|
It binds `100.64.0.2` specifically, **never** `0.0.0.0`: the far end of this
|
|
link is a public host, and the LAN has no business seeing an unauthenticated
|
|
inference endpoint.
|
|
|
|
```bash
|
|
sudo install -m 0644 deploy/vllm-headscale-proxy.service /etc/systemd/system/
|
|
sudo systemctl daemon-reload && sudo systemctl enable --now vllm-headscale-proxy
|
|
ss -lntp | grep 8000 # expect BOTH 127.0.0.1:8000 and 100.64.0.2:8000
|
|
```
|
|
|
|
The model id (`qwen3.6-35b`) goes into `LLM_MODEL` / `LLM_CHAT_MODEL` on the
|
|
VPS. Verified end to end: a grammar checkpoint from `petal.parodia.dev` returns
|
|
real suggestions in ~3s over the VPN.
|
|
|
|
---
|
|
|
|
## 4. Interim edge gate (delete when Phase 16 lands)
|
|
|
|
Petal authenticates nobody yet — `StaticResolver` hands every request the same
|
|
`local` user. On a public host that means anyone who finds the hostname can read
|
|
and write documents and fill the disk with image uploads, so Traefik holds the
|
|
door with basic auth until the OIDC flow exists.
|
|
|
|
Generate a credential:
|
|
|
|
```bash
|
|
htpasswd -nbB petal 'your-password' # or any bcrypt htpasswd generator
|
|
```
|
|
|
|
and put the resulting `user:hash` pair in `.env` as `PETAL_BASIC_AUTH`.
|
|
|
|
When Phase 16 lands, delete the `petal-auth` middleware label, the
|
|
`petal-health` router labels, and this section.
|
|
|
|
---
|
|
|
|
## 5. Backups
|
|
|
|
### On the VPS — folded into `parodia-backup`
|
|
|
|
Petal rides the host's existing offsite job (`/usr/local/bin/parodia-backup`,
|
|
`parodia-backup.timer`, nightly ~03:40): age-encrypted to S3, 14-day retention,
|
|
dead-man snitch. The host holds only the age *public* recipient, so it writes
|
|
backups it cannot itself decrypt.
|
|
|
|
```
|
|
push petal.db.age sqlite_file_dump /home/reala/petal/data/petal.db
|
|
```
|
|
|
|
`/home/reala/petal/.env` is in the same job's secrets tarball — it carries the
|
|
interim basic-auth hash and, from Phase 16, the OIDC client secret.
|
|
|
|
**Why `sqlite_file_dump` and not the script's existing `sqlite_dump`:** that
|
|
helper uses Python's `iterdump`, which **does not reproduce an FTS5 virtual
|
|
table**. It emits `documents_fts` as a raw `sqlite_master` row plus its shadow
|
|
tables, and replaying the result dies with `no such table: documents_fts` —
|
|
verified by round-tripping a real dump on 2026-07-27. Petal's cross-document
|
|
search would have been silently missing after any restore. `sqlite_file_dump`
|
|
runs `VACUUM INTO` instead: a genuine database file, virtual tables intact, WAL
|
|
folded in, no write lock. Restore is a copy rather than a replay.
|
|
|
|
> If `apply.db` ever gains a virtual table, it needs the same treatment.
|
|
|
|
### Restore (VPS)
|
|
|
|
```bash
|
|
age -d -i <offline-identity> petal.db.age > /tmp/petal.db # from S3
|
|
cd ~/petal
|
|
docker compose stop petal # stop writers first
|
|
mv data/petal.db data/petal.db.before-restore # keep the current state
|
|
rm -f data/petal.db-wal data/petal.db-shm # a stale WAL against a new file
|
|
cp /tmp/petal.db data/petal.db
|
|
docker compose start petal
|
|
docker compose logs petal --tail 5 # expect "database ready"
|
|
```
|
|
|
|
To sanity-check an archive before committing to it, have Petal open it in a
|
|
scratch directory — a clean exit means it reads end to end:
|
|
|
|
```bash
|
|
mkdir -p /tmp/restore-check && cp /tmp/petal.db /tmp/restore-check/petal.db
|
|
docker run --rm -v /tmp/restore-check:/data --user "$(id -u):$(id -g)" \
|
|
--entrypoint sh petal:local -c '/app/petal -backup /data/verify.db'
|
|
```
|
|
|
|
### ⚠️ millenia — the canonical instance — has no automated backup
|
|
|
|
**This is the real gap.** Her actual writing lives on millenia, and as of
|
|
2026-07-27 nothing backs it up on a schedule: `~/petal/backups` holds only
|
|
ad-hoc pre-deploy snapshots, the newest a month old. The empty VPS staging
|
|
database is currently better protected than the live one.
|
|
|
|
`deploy/backup-petal.sh` is written for exactly this and is not yet installed
|
|
there — it snapshots via `petal -backup` (`VACUUM INTO`, safe against the live
|
|
app), compresses, pushes off-box with a post-transfer size check, and prunes
|
|
both ends. What it does **not** yet do is encrypt at rest, which is being
|
|
decided separately.
|
|
|
|
A manual snapshot any time, no tooling required:
|
|
|
|
```bash
|
|
cd ~/petal && ./petal -backup ~/petal/backups/manual-$(date -u +%Y%m%dT%H%M%SZ).db
|
|
```
|
|
|
|
### ⚠️ millenia's Petal is unsupervised
|
|
|
|
It runs as a bare `./petal` with PPID 1 — no systemd unit, no screen session. A
|
|
crash or reboot leaves it down until someone notices. `deploy/piper.service` is
|
|
the pattern to copy if you want it supervised.
|
|
|
|
---
|
|
|
|
## Appendix — Piper on millenia (user systemd units)
|
|
|
|
millenia still runs Piper as user services; these are the original notes.
|
|
|
|
```bash
|
|
scp deploy/piper.service deploy/setup-piper.sh 192.168.1.212:/tmp/
|
|
ssh 192.168.1.212 'cd /tmp && sudo ./setup-piper.sh'
|
|
```
|
|
|
|
`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads
|
|
`en_US-amy-medium` into `~/piper/voices`, installs and enables `piper.service`
|
|
(loopback `:5005`), and smoke-tests it. Idempotent. Check it with
|
|
`systemctl status piper` / `journalctl -u piper -f`.
|
|
|
|
Chinese runs as a second instance (`piper-zh.service`, `:5006`,
|
|
`zh_CN-huayan-medium`):
|
|
|
|
```bash
|
|
scp deploy/piper-zh.service 192.168.1.212:~/.config/systemd/user/
|
|
ssh 192.168.1.212 'export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
|
~/piper/venv/bin/python -m piper.download_voices zh_CN-huayan-medium --data-dir ~/piper/voices
|
|
systemctl --user daemon-reload && systemctl --user enable --now piper-zh.service'
|
|
```
|
|
|
|
Petal's env then carries `TTS_ENDPOINT=http://127.0.0.1:5005`,
|
|
`TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and the matching voice ids. The handler
|
|
maps language → instance from config, so another language is another instance
|
|
plus an env pair, no code change.
|
|
|
|
**Piper version note:** piper-tts moved synthesis from `POST /` to
|
|
`POST /synthesize` in 1.6.0, with an identical request body. `TTS_PATH` selects
|
|
which — it defaults to `/`, and both the VPS compose and millenia's `start.sh`
|
|
now set `/synthesize`. If read-aloud starts returning 502 after a Piper upgrade,
|
|
that flag is the fix.
|
|
|
|
**The venv is fragile across Python upgrades.** On 2026-07-27 millenia's Piper
|
|
was found dead with **26,800+ failed restarts**, silently since the Jul 26
|
|
reboot — read-aloud had been falling back to browser Web Speech the whole time.
|
|
Root cause: an OS upgrade moved `/usr/bin/python3` from 3.13 to 3.14, and
|
|
`venv/bin/python3` is a *symlink to the system interpreter*, so the venv's
|
|
`lib/python3.13/site-packages` became invisible — `sys.path` contained no
|
|
site-packages at all. The failure surfaced as the misleading
|
|
`No module named piper.http_server` even though `http_server.py` was sitting
|
|
right there on disk.
|
|
|
|
Fix (what was done — recreating the venv, not repairing it):
|
|
|
|
```bash
|
|
systemctl --user stop piper.service piper-zh.service
|
|
mv ~/piper/venv ~/piper/venv.broken-py313
|
|
python3 -m venv ~/piper/venv
|
|
~/piper/venv/bin/pip install "piper-tts[http]"
|
|
~/piper/venv/bin/python -c 'import piper.http_server' # must not raise
|
|
systemctl --user start piper.service piper-zh.service
|
|
```
|
|
|
|
That reinstall lands 1.6.0, so it must be paired with `TTS_PATH=/synthesize` in
|
|
`start.sh` and a binary new enough to read that variable. Voices in
|
|
`~/piper/voices` survive and do not need re-downloading.
|
|
|
|
Worth knowing: `Restart=on-failure` will retry forever without ever alerting.
|
|
Neither service reports its health anywhere, which is why this went unnoticed
|
|
for a day. A `/api/tts` probe in uptime-kuma would have caught it.
|
|
|
|
Verify end to end (through Petal, including the ffmpeg transcode):
|
|
|
|
```bash
|
|
curl -sf -X POST localhost:8088/api/tts \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \
|
|
&& file /tmp/petal-tts.mp3 # expect: MPEG ADTS, layer III
|
|
```
|
|
|
|
A second identical call is served from the cache; a language with no configured
|
|
instance returns 404 so the client falls back to Web Speech.
|