# Help Center hosting — infrastructure runbook The public help center is served by the backend itself (host-dispatched SSR — see `backend/app/core/help_center_host.py`). This runbook covers the deploy-time pieces that live **outside** the repo: DNS, TLS and the host nginx on the production VPS. `frontend/nginx.conf` and the docker-compose files need **no changes**. > **As deployed on the ReplyPier cloud VPS (2026-07-14):** the base domain is > **`help.replypier.site`** (orgs serve at `{slug}.help.replypier.site`), not > the `help.replypier.site` this doc originally planned — that domain was never > registered, and the org only owns `replypier.site`. DNS is **Route53**, so the > wildcard cert is issued and auto-renewed with the `dns-route53` plugin. > Placeholders (``, ``, …) stand in for the live values; the > real ones live on the VPS and in ops notes, not in this public repo. ## 1. One-time platform setup ### DNS (Route53) Point a wildcard under the domain you own at the VPS. On the cloud box this is the `replypier.site` hosted zone (``): - `*.help.replypier.site. A ` - `*.help.replypier.site. AAAA ` One wildcard record covers every org — no per-org DNS. Create them via the API: ```bash aws route53 change-resource-record-sets --hosted-zone-id \ --change-batch file://wildcard.json # UPSERT A + AAAA for *.help. ``` Backend env (`backend.env` on the VPS): ``` HELP_CENTER_BASE_DOMAIN=help.replypier.site HELP_CENTER_TARGET_IPS=[""] # JSON array — see gotcha below ``` > **Gotcha:** `HELP_CENTER_TARGET_IPS` is a `frozenset` setting, so > pydantic-settings JSON-parses the env value. A bare IP or comma list > (``) fails validation and crash-loops the backend at startup — > it **must** be a JSON array: `[""]`. (Powers the per-org > custom-domain A-record verification in §2; unused by the wildcard site.) ### Wildcard certificate (DNS-01 via Route53) HTTP-01 cannot issue wildcards. A dedicated IAM user with the standard certbot Route53 policy (`route53:ListHostedZones` + `GetChange` on `*`, `ChangeResourceRecordSets` on the zone) has its creds at `~/.aws/credentials` (mode 600) on the box. Issue once: ```bash docker run --rm \ -v ~/replypier/certbot/conf:/etc/letsencrypt \ -v ~/.aws:/root/.aws:ro \ certbot/dns-route53 certonly --dns-route53 \ -d '*.help.replypier.site' -d 'help.replypier.site' \ --cert-name help.replypier.site \ --non-interactive --agree-tos -m ``` **Auto-renewal:** a daily cron runs the compose `certbot` service as a **one-shot**, then reloads nginx only if a cert actually changed: ```bash docker compose -f docker-compose.hostinger.yml \ run --rm --entrypoint certbot certbot \ renew --quiet --deploy-hook "echo reload-needed > /var/www/certbot/.reload" # ...then, if the flag file appeared: rm it && docker compose exec -T nginx nginx -s reload ``` It uses the `certbot/dns-route53` image and mounts `/home/replypier/.aws:/root/.aws:ro`, so the wildcard renews unattended (the renewal conf remembers `authenticator = dns-route53`). Existing HTTP-01 webroot certs still renew — the dns-route53 image is a superset of `certbot/certbot`. `renew` covers every cert in the shared `certbot/conf`, not just the wildcard. > **The `certbot` service must stay one-shot — never give it a long-running > loop entrypoint.** It previously used > `sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait ${!}; done;'`, > which broke renewal in two non-obvious ways: > > 1. **`compose run` inherits the entrypoint.** The "one-off" cron run never > exited, so `--rm` never fired (a container leaked *every night*) and the > cron's chained `&& nginx -s reload` never ran at all. > 2. **`sh -c '