docs/learning-api-preview-hetzner-setup.md
Learning API Preview on Hetzner + Cloudflare
This runbook covers what you need to do manually to make the automated preview workflow work.
Learning API Preview on Hetzner + Cloudflare
This runbook covers what you need to do manually to make the automated preview workflow work.
What This Setup Gives You
- One shared Hetzner VM hosting multiple PR preview stacks for
learning-api. - One stable public base URL (behind Cloudflare), with per-PR path routing:
https://<base-host>/pr-<PR_NUMBER>
- Existing preview workflow sets core-api preview
LEARNING_API_BASE_URLto that PR URL.
1) Hetzner VM Provisioning
Recommended starter VM:
16 vCPU / 32 GB RAM / 250 GB NVMe- Ubuntu 22.04 or 24.04
- Public IPv4
Provision with Hetzner CLI (manual):
hcloud server create \
--name studyflash-learning-api-preview \
--type cpx52 \
--image ubuntu-24.04 \
--location nbg1 \
--ssh-key <your_ssh_key_name>
Then SSH in and bootstrap:
- Docker Engine + Docker Compose plugin
- cloudflared (Cloudflare Tunnel client)
- preview root directory
/opt/studyflash/learning-api-previews - shared Docker network
learning-api-preview-proxy
Make sure the SSH user used by GitHub Actions can run:
dockerdocker composedocker run(one-time Traefik bootstrap is handled by deploy script)
2) Cloudflare Setup
You need one Cloudflare Tunnel from the VM to Cloudflare.
- Create a tunnel and install credentials on the VM.
- Create DNS for one host, for example:
learning-api-preview.preview.your-domain.com
- Point that DNS host to the tunnel.
- Configure tunnel ingress to forward that host to local Traefik:
- service:
http://localhost:8080
- service:
- Run cloudflared as a system service.
Result:
- All PR routes are path-based under the same host:
/pr-123,/pr-456, etc.
3) Traefik Dynamic Router (Auto-Managed)
The deploy script auto-creates a Traefik container (learning-api-preview-traefik) if missing and keeps it running on:
127.0.0.1:8080(for cloudflared ingress)- Docker network:
learning-api-preview-proxy
Each PR stack adds Docker labels on the API container:
Host(<preview-host>) && PathPrefix(/pr-<n>)- Strip-prefix middleware so backend still serves at
/. - No per-PR web server files, no reloads.
4) GitHub Repo Configuration
Required Repository Secrets
HETZNER_PREVIEW_HOST- VM IP or hostname
HETZNER_PREVIEW_SSH_PRIVATE_KEY- Private key corresponding to SSH public key authorized on VM
HETZNER_PREVIEW_SSH_USER- Example:
deploy-preview(falls back torootif empty)
- Example:
LEARNING_API_PREVIEW_BASE_URL- Example:
https://learning-api-preview.preview.your-domain.com
- Example:
Optional Fallback Secrets
CORE_API_UAT_BASE_URL- UAT core-api URL used when core-api preview is skipped.
- Default if unset:
https://api.uat.studyflash.ch
LEARNING_API_UAT_BASE_URL- UAT learning-api URL used when learning-api preview is skipped.
Required Infisical Access
- The GitHub machine identity used by
pnpm run infisical-authmust have read access to:- path
/backend/instagingfor core-api preview worker secrets - path
/learning-api/instagingfor learning-api base preview env
- path
5) Files Added for Automation
apps/learning-api/docker-compose.preview.ymlapps/learning-api/scripts/preview/deploy_learning_api_preview.shapps/learning-api/scripts/preview/cleanup_learning_api_preview.sh- Workflow integration:
.github/workflows/deploy_preview_env.yaml.github/workflows/cleanup-preview.yml
6) Preview Lifecycle
On PR opened/reopened/synchronize:
- Workflow determines changed scopes (
core-api,learning-api,supabaseschema/migrations). portalpreview always deploys.learning-apipreview deploys only whenlearning-apiorsupabaseschema changed.core-apipreview deploys whencore-apichanged, or when dependency chain requires it (learning-api/supabasechange).- If
learning-apipreview is deployed, workflow exports/learning-api/secrets from Infisicalstagingand overrides preview runtime values:SUPABASE_URLSUPABASE_SERVICE_ROLE_KEYAPI_KEY(aligned with core-api previewLEARNING_API_KEY)- internal service wiring is always forced at runtime:
API_URL=http://api:8000REDIS_URL=redis://redis:6379/0CELERY_BROKER_URL=redis://redis:6379/0CELERY_RESULT_BACKEND=redis://redis:6379/0
- If preview deploy is skipped for a service, workflow reuses configured UAT URL for that service.
On PR closed:
- Workflow runs cleanup script on Hetzner.
- Stack is removed; Traefik route disappears automatically when container is gone.
7) Validate End-to-End
- Open a PR.
- Wait for
Deploy Preview Environmentworkflow. - Verify PR comment contains Learning API preview URL.
- Open:
https://<base-host>/pr-<PR_NUMBER>/docs
- Trigger a core-api path that calls learning-api and confirm no 5xx from base URL mismatch.
- Close PR and verify cleanup removed:
- Docker project
pr-<PR_NUMBER>
- Docker project
8) Operational Notes
- This setup intentionally avoids one DNS record per PR.
- Path-based routing simplifies Cloudflare and keeps lock-in low.
- Traefik with Docker provider removes route file management overhead.
- If parser previews are needed later, add an opt-in path (label/manual trigger), not default for every PR.