Bootstrap prerequisites (accounts + credentials)¶
Self-contained checklist of the one-time account and credential work an operator must
complete before the first tofu apply and first deploy in
PROD_RUNBOOK § First-time bootstrap. Everything here
lives in this repo so a second operator can bootstrap from the repo alone (the bus-factor
goal of #805).
Tailscale auth model (migrated 2026-08-03): this project now uses OAuth clients for all Tailscale auth — they do not expire, unlike the old Free-plan Personal tokens. See ADR-143 and PROD_RUNBOOK § Tailscale credentials (OAuth clients) for the full architecture. Three OAuth clients (GH Actions secret pairs):
TS_INFRA_OAUTH_CLIENT_ID/_SECRET— terraformtailscaleproviderTS_OAUTH_CLIENT_ID/_SECRET— GHA runner + VPS tailnet joinTS_ACL_OAUTH_CLIENT_ID/_SECRET— ACL GitOps actionSecret retrieval is password-manager-agnostic. Commands below show values as
<placeholders>. Retrieve each from whatever secrets store you use (pass, a.envyou keep offline,op read …if you use 1Password, etc.). No specific manager is required.
A. Operator laptop tooling¶
- [ ]
brew install opentofu sops age actionlint shellcheck - [ ]
gh auth login(GitHub CLI, authenticated tochipi/podcast_scraper) - [ ] An Ed25519 SSH key at
~/.ssh/id_ed25519(ssh-keygen -t ed25519if absent) — OpenTofu registers its public half on the VPS as the operator key.
B. Hetzner Cloud account¶
- [ ] Account at hetzner.com/cloud with a billing method (EU residents: SEPA is cheapest).
- [ ] A dedicated Cloud Project named
podcast-scraper-prod(clean billing line + scoped tokens). - [ ] In that project: Settings → API Tokens → Generate with Read & Write scope → this is
HCLOUD_TOKEN. - [ ] Confirm an EU location is available: Falkenstein (fsn1) or Nuremberg (nbg1) (RFC-082 Decision 1).
C. Tailscale (Free or Premium plan)¶
- [ ] A tailnet (note its name, e.g.
tail-xxxxx.ts.net). - [ ] Settings → DNS → MagicDNS enabled.
- [ ] Settings → DNS → HTTPS Certificates enabled (so
tailscale cert/tailscale servecan issue Let's Encrypt forprod-podcast.<tailnet>). - [ ] Three OAuth clients (Admin → Settings → OAuth clients):
- [ ]
TS_INFRA_OAUTH(terraform provider) — scopes:auth_keys,devices:core,dns,policy_file; tags:tag:prod,tag:dr-drill - [ ]
TS_OAUTH(device join) — scope:auth_keys; tag:tag:gha-deployer - [ ]
TS_ACL_OAUTH(GitOps action) — scope:policy_file - [ ] Access Controls (
policy.hujson): - [ ] Owners for
tag:prodandtag:dr-drill(self-owned, per ADR-143) - [ ] Owners for
tag:gha-deployer - [ ] Rule
tag:gha-deployer→tag:prod:22andtag:dr-drill:22(SSH) - [ ] Rule operator's user →
tag:prod:443,:80,:22; same fortag:dr-drill
D. sops + age (Terraform state encryption)¶
- [ ]
age-keygen -o ~/.config/sops/age/keys.txt. - [ ] Copy the public key into
infra/.sops.yaml(replacing theage1PLACEHOLDER…value; commit-safe). - [ ] Save the private key contents to your secrets store as
sops/podcast-scraper/tofu-state-age-key. - [ ] Verify round-trip:
echo test | sops -e --age "$(grep 'public key:' ~/.config/sops/age/keys.txt | sed 's/.*: //')" /dev/stdin >/dev/null.
E. Runtime service accounts (host .env, staged later as PROD_* secrets)¶
These are needed for the running stack, staged once in repo settings and rendered into the host
.env by deploy-prod.yml (see PROD_RUNBOOK § Stage .env).
Stage incrementally — a missing one just disables that feature (the stack still starts).
- [ ] LLM providers (whichever your prod profile uses;
cloud_balanceddefault uses OpenAI + Gemini): OpenAI, Anthropic, Gemini, Mistral, DeepSeek, Grok API keys. - [ ] GlitchTip (self-hosted): homelab
http://homelab:8090→ create three projects (api,pipeline,viewer) → one DSN each. Public error ingest viatelemetry.closelistening.app. - [ ] Alloy remote-write endpoints (no signup required):
REMOTE_WRITE_URL=http://homelab:8428/api/v1/writeandLOGS_WRITE_URL=http://homelab:9428/insert/loki/api/v1/push— homelab VictoriaMetrics/Logs, tailnet-reachable from the VPS. - [ ] Backup repo PAT (
BACKUP_REPO_TOKEN, optional) and outbound job webhook URL (optional).
F. Stage the infrastructure GHA secrets¶
With the above in hand, stage the infra credentials (the runtime PROD_* secrets are staged
separately per the runbook):
gh secret set HCLOUD_TOKEN --repo chipi/podcast_scraper --app actions --body '<from §B>'
gh secret set TS_INFRA_OAUTH_CLIENT_ID --repo chipi/podcast_scraper --app actions --body '<client-id from §C TS_INFRA_OAUTH>'
gh secret set TS_INFRA_OAUTH_SECRET --repo chipi/podcast_scraper --app actions --body '<client-secret from §C TS_INFRA_OAUTH>'
gh secret set TS_OAUTH_CLIENT_ID --repo chipi/podcast_scraper --app actions --body '<client-id from §C TS_OAUTH>'
gh secret set TS_OAUTH_SECRET --repo chipi/podcast_scraper --app actions --body '<client-secret from §C TS_OAUTH>'
gh secret set TS_ACL_OAUTH_CLIENT_ID --repo chipi/podcast_scraper --app actions --body '<client-id from §C TS_ACL_OAUTH>'
gh secret set TS_ACL_OAUTH_SECRET --repo chipi/podcast_scraper --app actions --body '<client-secret from §C TS_ACL_OAUTH>'
gh secret set TFSTATE_AGE_KEY --repo chipi/podcast_scraper --app actions --body "$(cat ~/.config/sops/age/keys.txt)"
gh secret set BACKUP_REPO_TOKEN --repo chipi/podcast_scraper --app actions --body '<backup-repo-pat, optional>'
gh secret set OPERATOR_SSH_PUBLIC_KEY --repo chipi/podcast_scraper --body "$(cat ~/.ssh/id_ed25519.pub)"
gh variable set TAILNET_NAME --repo chipi/podcast_scraper --body 'tail-xxxxx.ts.net'
PROD_SSH_PRIVATE_KEY (the CI deploy key) and PROD_TAILNET_FQDN are set during bootstrap, not
here — see PROD_RUNBOOK § GitHub Actions SSH to prod
and § First tofu apply.
G. Exit criterion (smoke before tofu apply)¶
- [ ] Hetzner API token works:
HCLOUD_TOKEN='<from §B>'
curl -fsS -H "Authorization: Bearer $HCLOUD_TOKEN" https://api.hetzner.cloud/v1/server_types \
| jq '.server_types[] | select(.name=="cx33") | {name, prices: .prices[0]}'
# Expect a JSON object (NOT 401/403).
- [ ]
gh secret list --repo chipi/podcast_scrapershows the §F secrets. - [ ] Your secrets store holds: Hetzner token, three OAuth client pairs (from §C), sops-age private key.
Once all boxes are checked, proceed to PROD_RUNBOOK § First-time bootstrap.