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_URL to 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:

  • docker
  • docker compose
  • docker run (one-time Traefik bootstrap is handled by deploy script)

2) Cloudflare Setup

You need one Cloudflare Tunnel from the VM to Cloudflare.

  1. Create a tunnel and install credentials on the VM.
  2. Create DNS for one host, for example:
    • learning-api-preview.preview.your-domain.com
  3. Point that DNS host to the tunnel.
  4. Configure tunnel ingress to forward that host to local Traefik:
    • service: http://localhost:8080
  5. 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 to root if empty)
  • LEARNING_API_PREVIEW_BASE_URL
    • Example: https://learning-api-preview.preview.your-domain.com

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-auth must have read access to:
    • path /backend/ in staging for core-api preview worker secrets
    • path /learning-api/ in staging for learning-api base preview env

5) Files Added for Automation

  • apps/learning-api/docker-compose.preview.yml
  • apps/learning-api/scripts/preview/deploy_learning_api_preview.sh
  • apps/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:

  1. Workflow determines changed scopes (core-api, learning-api, supabase schema/migrations).
  2. portal preview always deploys.
  3. learning-api preview deploys only when learning-api or supabase schema changed.
  4. core-api preview deploys when core-api changed, or when dependency chain requires it (learning-api/supabase change).
  5. If learning-api preview is deployed, workflow exports /learning-api/ secrets from Infisical staging and overrides preview runtime values:
    • SUPABASE_URL
    • SUPABASE_SERVICE_ROLE_KEY
    • API_KEY (aligned with core-api preview LEARNING_API_KEY)
    • internal service wiring is always forced at runtime:
      • API_URL=http://api:8000
      • REDIS_URL=redis://redis:6379/0
      • CELERY_BROKER_URL=redis://redis:6379/0
      • CELERY_RESULT_BACKEND=redis://redis:6379/0
  6. If preview deploy is skipped for a service, workflow reuses configured UAT URL for that service.

On PR closed:

  1. Workflow runs cleanup script on Hetzner.
  2. Stack is removed; Traefik route disappears automatically when container is gone.

7) Validate End-to-End

  1. Open a PR.
  2. Wait for Deploy Preview Environment workflow.
  3. Verify PR comment contains Learning API preview URL.
  4. Open:
    • https://<base-host>/pr-<PR_NUMBER>/docs
  5. Trigger a core-api path that calls learning-api and confirm no 5xx from base URL mismatch.
  6. Close PR and verify cleanup removed:
    • Docker project pr-<PR_NUMBER>

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.