# Petal on the parodia.dev VPS. # # docker compose up -d --build # # Fronted by the host's existing Traefik (external `traefik` network, the # `web-secure` entrypoint and the `default` cert resolver — same convention the # other services on this box use). Petal itself never binds a host port; the # only way in is through Traefik over HTTPS. # # Read-aloud runs as two sibling containers rather than host systemd services: # each Piper HTTP server loads exactly one voice, the host has no lingering # user session to keep systemd units alive, and keeping them on the internal # network means the TTS ports are unreachable from anywhere but Petal. # # Copy deploy/petal.env.example to .env before the first `up`. name: petal services: petal: build: context: . dockerfile: Dockerfile image: petal:local container_name: petal restart: unless-stopped # ./data is a bind mount, so the image's own `petal` user (uid 10001) has no # claim on it — the host's ownership wins and the container can't open # petal.db. Run as whoever owns the stack directory instead. Keeping it the # host user (rather than chowning ./data to 10001) is deliberate: the backup # script gzips snapshots in place from the host, so the host account needs # write access to the same directory. Still never root. user: "${PETAL_UID:-1001}:${PETAL_GID:-1001}" env_file: .env environment: # Fixed by the image layout; kept here so they're visible at a glance. PORT: "8080" DATABASE_PATH: /data/petal.db IMAGE_DIR: /data/images TTS_CACHE_DIR: /data/tts # DreamDict's built dictionary, read-only, deployed into the data volume # (see deploy/README.md). Absent it, word lookups fall back to the # embedded English/Chinese datasets rather than failing. DICT_PATH: /data/dict.db # Piper sidecars. Each server loads one voice, so English and Chinese are # separate containers; the handler maps language → instance from config. TTS_ENDPOINT: http://piper-en:5000 TTS_ENDPOINT_ZH: http://piper-zh:5000 # A language is discovered from the TTS_ENDPOINT_/TTS_VOICE_ # pair, so fr and es cost a service and two lines rather than a code # change. is the base tag — an env var name can't hold pt-PT's # hyphen, and there is one Portuguese voice loaded either way. TTS_ENDPOINT_PT: http://piper-pt:5000 TTS_ENDPOINT_FR: http://piper-fr:5000 TTS_ENDPOINT_ES: http://piper-es:5000 # The sidecars run piper-tts 1.6.0, which serves synthesis on # /synthesize; millenia's older server keeps the default "/". TTS_PATH: /synthesize # The companion's bedtime nag and night mode read the local clock. TZ: ${TZ:-Europe/Lisbon} volumes: # A bind mount, not a named volume: petal.db must be trivially reachable # from the host for the nightly backup and for a restore. - ./data:/data # Mount-liveness guard. On the VPS ./data is an encrypted LUKS volume, and # the mountpoint directory still exists when that volume is NOT mounted — # so without this, a boot where the unlock failed would start Petal # against an empty unencrypted directory and quietly serve a blank # database. .volume-ok lives on the encrypted filesystem, and # create_host_path: false turns its absence into a container start # failure instead. Harmless elsewhere: create the file once and it is a # no-op. See deploy/README.md §6. - type: bind source: ./data/.volume-ok target: /data/.volume-ok read_only: true bind: create_host_path: false networks: - traefik - internal depends_on: - piper-en - piper-zh - piper-pt labels: traefik.enable: "true" traefik.docker.network: traefik traefik.http.routers.petal.rule: Host(`${PETAL_HOST:-petal.parodia.dev}`) traefik.http.routers.petal.entrypoints: web-secure traefik.http.routers.petal.tls: "true" traefik.http.routers.petal.tls.certResolver: default traefik.http.routers.petal.service: petal # No edge gate: Petal authenticates for itself now (Authentik OIDC), so # every /api route answers 401 without a session and the only thing served # to an anonymous visitor is the app shell and its sign-in redirect. The # basic-auth middleware that stood here until Phase 16 — plus the separate # unauthenticated router /api/health needed to escape it — is gone; a # second password in front of a real login is just one more thing to lose. traefik.http.routers.petal.middlewares: compression@file,petal-headers traefik.http.services.petal.loadbalancer.server.port: "8080" # HSTS is the edge's business — it is a statement about the TLS # termination, which happens here and not in the container. # # The Content-Security-Policy that used to sit alongside it has moved into # the app (see securityHeaders in cmd/server/main.go). customresponseheaders # *overwrites*, so a policy set here would silently replace the stricter # one an individual route chooses for itself — which is exactly what the # image store does to keep an uploaded SVG from running as a page. A rule # the edge can quietly undo is not a rule. X-Content-Type-Options and # Referrer-Policy moved with it for the same reason: one place to read, # and no dependence on this file being deployed alongside the binary. traefik.http.middlewares.petal-headers.headers.customresponseheaders.Strict-Transport-Security: max-age=31536000; includeSubDomains piper-en: build: context: deploy/piper image: petal-piper:local container_name: petal-piper-en restart: unless-stopped environment: PIPER_VOICE: ${TTS_VOICE_EN:-en_US-amy-medium} volumes: - piper-voices:/voices networks: - internal piper-zh: build: context: deploy/piper image: petal-piper:local container_name: petal-piper-zh restart: unless-stopped environment: PIPER_VOICE: ${TTS_VOICE_ZH:-zh_CN-huayan-medium} volumes: - piper-voices:/voices networks: - internal # European Portuguese, for the pt-PT pair. pt_PT-tugão-medium is the *only* # European voice in Piper's catalogue — the other five Portuguese models are # all pt_BR — so the default anyone reaches for is the Brazilian one, exactly # as it was with the Hunspell dictionary in Phase 21. Named here rather than # left to the image default for that reason. piper-pt: build: context: deploy/piper image: petal-piper:local container_name: petal-piper-pt restart: unless-stopped environment: PIPER_VOICE: ${TTS_VOICE_PT:-pt_PT-tugão-medium} volumes: - piper-voices:/voices networks: - internal # French, for the fr pair. The opposite situation to Portuguese: every French # voice Piper ships is fr_FR, so there is no wrong country to land on by # default, and the name is plain ASCII so the entrypoint's percent-encoded # fallback (added for tugão) never has to fire. siwis-medium to match the # register of the other three. piper-fr: build: context: deploy/piper image: petal-piper:local container_name: petal-piper-fr restart: unless-stopped environment: PIPER_VOICE: ${TTS_VOICE_FR:-fr_FR-siwis-medium} volumes: - piper-voices:/voices networks: - internal # Spanish, for the es pair — and the Portuguese trap rather than the French # one. Piper's catalogue has nine Spanish voices, six of them es_ES, and the # obvious pick (es_ES-davefx-medium, which the build plan itself named) is # peninsular. The es pack is written in neutral Latin American Spanish, so a # Castilian voice would read it aloud in the accent the copy was written to # avoid — the same wrong-country default that pt-PT hit through packaging, # arriving here through the voice list. Only two Latin American voices exist, # es_AR-daniela-high and es_MX; Mexican is the neutral broadcast standard and # ald-medium matches the register of the other four. ASCII, so the # percent-encoded download fallback added for tugão never has to fire. piper-es: build: context: deploy/piper image: petal-piper:local container_name: petal-piper-es restart: unless-stopped environment: PIPER_VOICE: ${TTS_VOICE_ES:-es_MX-ald-medium} volumes: - piper-voices:/voices networks: - internal networks: # Created and owned by the host's Traefik stack. traefik: external: true # Petal ↔ Piper only. Not reachable from the internet or the other stacks. internal: driver: bridge volumes: # Downloaded voice models, shared read-mostly by both Piper instances so the # same model is never fetched twice. piper-voices: