Phase 15: containerize Petal for the parodia.dev VPS

Deploy plumbing so Petal can run on the public VPS behind the Traefik
already on that box, with vLLM reached over headscale.

- Dockerfile: node build -> go build -> alpine runtime. CGO stays off
  (modernc SQLite is pure Go), so the runtime layer exists only for
  ffmpeg (read-aloud transcodes Piper's WAV) and tzdata (the companion's
  bedtime nag and night mode read the local clock). Runs as uid 10001
  with /data as the single writable mount.

- docker-compose.yml: Traefik labels following this host's convention
  (external `traefik` network, `web-secure` entrypoint, `default` cert
  resolver). Petal publishes no host port. ./data is a bind mount, not a
  named volume, so the nightly backup and a restore are reachable from
  the host.

- Piper runs as two sibling containers rather than host systemd units.
  The plan assumed Piper was already installed on the VPS; it is not,
  the host has no lingering user session to keep user units alive, and
  containers keep the TTS ports on an internal network unreachable from
  anywhere but Petal. One image, voice chosen per service, model cached
  in a shared volume -- so the pt-PT voice is a new service, not a new
  image.

- db.Backup + a `-backup` flag: VACUUM INTO, not a file copy. Petal runs
  in WAL mode, so the newest committed pages may live in petal.db-wal;
  copying the three files separately can capture a torn mid-checkpoint
  state. VACUUM INTO reads one coherent snapshot without taking a write
  lock, and emits a single file with no -wal/-shm companions. Refuses an
  existing destination so a failed run can't destroy the last good
  backup.

- deploy/backup-petal.sh: nightly snapshot, compress, push to millenia
  over headscale with a post-transfer size check, prune both sides.

- deploy/petal.env.example: LLM_TIMEOUT raised 30s -> 90s for the
  WAN+VPN round trip, since the voice and collocation passes send a
  whole document and the timeout is a hard deadline on Complete.
This commit is contained in:
prosolis
2026-07-26 23:08:22 -07:00
parent dae1213c68
commit 8410b6315b
10 changed files with 596 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Nightly off-VPS backup of Petal's database.
#
# ./backup-petal.sh # snapshot, compress, push off-box, prune
# ./backup-petal.sh --local-only # snapshot + prune, skip the remote push
#
# Run it from cron on the VPS (see deploy/README.md). The snapshot itself goes
# through `petal -backup`, which uses SQLite's VACUUM INTO: one coherent file
# including anything still in the WAL, taken without a write lock, so it is
# safe against the live running app. That is why this script never touches
# petal.db / -wal / -shm directly — copying those three separately can capture
# a torn mid-checkpoint state.
#
# Everything below is overridable from the environment.
set -euo pipefail
# Stack directory (holds docker-compose.yml and ./data).
STACK_DIR="${STACK_DIR:-$HOME/petal}"
# Where snapshots land on the VPS before being pushed off-box. Inside ./data so
# the container can write it through the existing bind mount.
LOCAL_DIR="${LOCAL_DIR:-$STACK_DIR/data/backups}"
# Off-VPS destination: millenia over headscale. Empty disables the push.
REMOTE_HOST="${REMOTE_HOST:-100.64.0.2}"
REMOTE_USER="${REMOTE_USER:-}"
REMOTE_DIR="${REMOTE_DIR:-petal-backups}"
# Retention, in days, on each side.
KEEP_LOCAL_DAYS="${KEEP_LOCAL_DAYS:-7}"
KEEP_REMOTE_DAYS="${KEEP_REMOTE_DAYS:-30}"
local_only=0
[ "${1:-}" = "--local-only" ] && local_only=1
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
name="petal-${stamp}.db"
cd "$STACK_DIR"
echo ">> snapshotting to data/backups/${name}"
# The container writes to its own /data mount; ./data/backups is the same
# directory seen from the host.
docker compose exec -T petal /app/petal -backup "/data/backups/${name}"
snapshot="${LOCAL_DIR}/${name}"
[ -s "$snapshot" ] || { echo "snapshot missing or empty: $snapshot" >&2; exit 1; }
echo ">> compressing"
gzip -9 "$snapshot"
archive="${snapshot}.gz"
echo " $(du -h "$archive" | cut -f1) ${archive}"
if [ "$local_only" -eq 0 ] && [ -n "$REMOTE_HOST" ]; then
target="${REMOTE_HOST}"
[ -n "$REMOTE_USER" ] && target="${REMOTE_USER}@${REMOTE_HOST}"
echo ">> pushing to ${target}:${REMOTE_DIR}/"
ssh -o BatchMode=yes "$target" "mkdir -p '${REMOTE_DIR}'"
scp -q -o BatchMode=yes "$archive" "${target}:${REMOTE_DIR}/"
# Verify by size rather than trusting scp's exit code alone — a truncated
# transfer that still exits 0 would leave a backup that only looks fine.
local_size="$(stat -c%s "$archive")"
remote_size="$(ssh -o BatchMode=yes "$target" "stat -c%s '${REMOTE_DIR}/$(basename "$archive")'")"
if [ "$local_size" != "$remote_size" ]; then
echo "size mismatch after transfer: local ${local_size}, remote ${remote_size}" >&2
exit 1
fi
echo " verified ${remote_size} bytes"
echo ">> pruning remote copies older than ${KEEP_REMOTE_DAYS} days"
ssh -o BatchMode=yes "$target" \
"find '${REMOTE_DIR}' -name 'petal-*.db.gz' -type f -mtime +${KEEP_REMOTE_DAYS} -delete"
elif [ "$local_only" -eq 1 ]; then
echo ">> --local-only: skipping the remote push"
else
echo ">> REMOTE_HOST is empty: skipping the remote push" >&2
fi
echo ">> pruning local copies older than ${KEEP_LOCAL_DAYS} days"
find "$LOCAL_DIR" -name 'petal-*.db.gz' -type f -mtime "+${KEEP_LOCAL_DAYS}" -delete
echo ">> done"
+49
View File
@@ -0,0 +1,49 @@
# Petal — production environment for the parodia.dev VPS.
# Copy to the stack directory as `.env` (docker-compose.yml reads it via
# env_file) and fill in the model names. Values the image already fixes
# (PORT, DATABASE_PATH, IMAGE_DIR, TTS_CACHE_DIR, TTS endpoints) are set in
# docker-compose.yml, not here.
# --- Routing -----------------------------------------------------------------
# Must match the DNS A record and the Traefik Host() rule.
PETAL_HOST=petal.parodia.dev
# Absolute origin the app knows itself by. Phase 16's OIDC redirect URI is
# built from this, so it has to be the real public HTTPS origin.
BASE_URL=https://petal.parodia.dev
# The companion's bedtime nag and the night theme read the container clock.
TZ=Europe/Lisbon
# --- LLM (millenia, over headscale) ------------------------------------------
# The only cross-VPN dependency. Petal degrades warmly when it's unreachable:
# spell check, gloss, garden, search, export and read-aloud all keep working and
# the status bar shows 小助手在休息 · Petal's helper is resting.
#
# 100.64.0.2 is millenia on the headscale network. vLLM must be bound to that
# interface (NOT 0.0.0.0 — this host is public); see deploy/README.md.
LLM_BACKEND=vllm
LLM_ENDPOINT=http://100.64.0.2:8000
LLM_MODEL=
LLM_CHAT_MODEL=
# 30s is the local-network default. Over WAN + VPN, with the voice and
# collocation passes sending a whole document, that truncates real work — the
# request is a hard deadline on Complete, and a timeout surfaces as the same
# warm 502 as an unreachable model. 90s leaves headroom without letting a
# genuinely wedged backend hang the pass forever.
LLM_TIMEOUT=90s
# --- Read-aloud (Piper sidecars) ---------------------------------------------
# Endpoints are wired in docker-compose.yml; these pick the voice each sidecar
# loads. Changing one means recreating that container so it downloads the model.
TTS_VOICE_EN=en_US-amy-medium
TTS_VOICE_ZH=zh_CN-huayan-medium
TTS_AUDIO_FORMAT=mp3
TTS_TIMEOUT=15s
# --- Auth (Phase 16 — not wired yet) -----------------------------------------
# Authentik already runs on this host. Filled in when the OIDC flow lands.
# SESSION_SECRET=
# AUTHENTIK_URL=https://auth.parodia.dev
# AUTHENTIK_CLIENT_ID=petal
# AUTHENTIK_CLIENT_SECRET=
# PETAL_ALLOWED_SUBS=
+37
View File
@@ -0,0 +1,37 @@
# Piper neural-TTS HTTP server — the read-aloud backend Petal proxies to.
#
# One image, any voice: the model is named by PIPER_VOICE at runtime and
# downloaded into the shared /voices volume on first start. Each Piper server
# loads exactly one voice, so a new language is a new service in
# docker-compose.yml, not a new image (English and Chinese today; pt-PT lands
# with the Portuguese pair).
#
# python:3.12 rather than 3.13 — piper-tts pulls onnxruntime, whose wheel
# coverage for 3.13 still lags.
FROM python:3.12-slim
RUN pip install --no-cache-dir "piper-tts[http]" \
&& useradd -m -u 10002 piper
ENV PIPER_VOICE=en_US-amy-medium \
PIPER_DATA_DIR=/voices \
PIPER_PORT=5000
RUN mkdir -p /voices && chown piper:piper /voices
VOLUME ["/voices"]
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER piper
EXPOSE 5000
# The server has no dedicated health route, so synthesizing a single word is
# the honest check: it proves the model loaded, not just that a port is open.
HEALTHCHECK --interval=60s --timeout=20s --start-period=180s --retries=3 \
CMD python -c "import os,urllib.request,json; \
urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:'+os.environ['PIPER_PORT']+'/', \
data=json.dumps({'text':'ok','voice':os.environ['PIPER_VOICE']}).encode(), \
headers={'Content-Type':'application/json'}), timeout=15).read(1)"
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Fetch the configured voice if the shared volume doesn't have it yet, then
# serve it. The download is the only step that needs the internet, and it runs
# once per voice for the life of the volume — Petal itself stays offline-first.
set -euo pipefail
voice="${PIPER_VOICE:?PIPER_VOICE must be set}"
data_dir="${PIPER_DATA_DIR:-/voices}"
port="${PIPER_PORT:-5000}"
if [ ! -f "${data_dir}/${voice}.onnx" ]; then
echo ">> downloading voice ${voice} into ${data_dir}"
python -m piper.download_voices "${voice}" --data-dir "${data_dir}"
fi
echo ">> serving ${voice} on :${port}"
# 0.0.0.0 is safe here: the container sits on Petal's internal compose network
# with no published ports, so only Petal can reach it.
exec python -m piper.http_server \
-m "${voice}" \
--data-dir "${data_dir}" \
--host 0.0.0.0 --port "${port}"