Backup & restore process — PortfolioAI
Discipline d'exit propre indépendante de Supabase. On garde notre propre archive
pg_dumpstandard, restorable sur n'importe quel Postgres (Neon, Fly Postgres, VPS, RDS…) sans dépendre du format proprio des snapshots managés Supabase. Cadence weekly + rétention 30 backups (~7 mois d'historique). Supabase free tier fait déjà des snapshots quotidiens 7j en parallèle → notre backup R2 est le filet long-terme, pas le rolling court. Workflow source :.github/workflows/backup-postgres.yml.
Quick links
| Quoi | Où |
|---|---|
Bucket R2 portfolioai-backups (UI) |
https://dash.cloudflare.com/8f2780696b5e520f85b5fc80413c4c3f/r2/default/buckets/portfolioai-backups |
| R2 dashboard global | dash.cloudflare.com/.../r2/buckets |
| R2 API tokens (rotation) | dash.cloudflare.com/.../r2/api-tokens |
| Workflow Actions (history + manual trigger) | github.com/jv3n/trade/actions/workflows/backup-postgres.yml |
| Workflow source | .github/workflows/backup-postgres.yml |
Secret Manager supabase-db-url (source de la DB URL) |
console.cloud.google.com/.../supabase-db-url |
| Snapshots Supabase natifs (rétention 7j, format proprio) | supabase.com/.../database/backups |
| Trigger manuel via CLI | gh workflow run backup-postgres.yml puis gh run watch |
| Lister les backups en CLI | aws s3 ls s3://portfolioai-backups/ --endpoint-url https://8f2780696b5e520f85b5fc80413c4c3f.r2.cloudflarestorage.com |
8f2780696b5e520f85b5fc80413c4c3f= ton Cloudflare Account ID (visible dans l'URL du dashboard R2 ou dans le GitHub SecretR2_ACCOUNT_ID). À substituer manuellement la 1re fois, ou bookmark direct dans le navigateur après le 1er accès UI.
Le rituel automatique
- Cron :
0 4 * * 0(chaque dimanche 4 AM UTC, ≈ samedi soir minuit Eastern, creux d'activité) - Pipeline : WIF auth → install
postgresql-client-16→ fetchsupabase-db-urldepuis Secret Manager →pg_dump | gzip > backup-<ISO-timestamp-UTC>.sql.gz→aws s3 cpvers R2 → prune les objets au-delà des 30 derniers - Échec : workflow run failed → GitHub envoie une notif mail au committer du workflow. Re-déclenchable manuellement via
workflow_dispatch. - Manual spot backup : avant une opération risquée (migration schema, fix SQL manuel) ou un restore drill, déclencher à la main via UI Actions ou
gh workflow run backup-postgres.yml. Recommandé aussi si tu sais que tu as fait du contenu critique pendant la semaine et que tu ne veux pas attendre dimanche.
Setup pré-requis (one-shot)
À faire avant que le workflow tourne pour la 1re fois. Tous les inputs sont stables ensuite.
1. Cloudflare R2 (bucket + API token)
- Compte Cloudflare : créer un compte gratuit sur
cloudflare.comsi pas déjà fait. - Activer R2 : Dashboard Cloudflare → R2 → « Get Started ». Free tier auto-actif (10 GB storage + 10 M Class A operations + 1 M Class B / mois).
- Créer le bucket :
wrangler r2 bucket create portfolioai-backups # OU via UI : R2 → Create bucket → name = portfolioai-backups → Standard → Create - Générer un API token Object Read & Write :
- R2 → Manage R2 API Tokens → Create API token
- Permissions : Object Read & Write (pas Admin Read & Write — principe du moindre privilège)
- Specify bucket :
portfolioai-backupsuniquement - TTL : laisser permanent (rotation manuelle si compromis)
- Cliquer Create → noter les 3 valeurs (visibles une seule fois) :
Access Key IDSecret Access KeyAccount ID(visible dans l'URL du dashboard R2, format<hash>.r2.cloudflarestorage.com)
2. GitHub Secrets + Variables (5 valeurs)
3 secrets R2 comme repository secrets (pas environment secrets — le workflow backup n'est pas gated par un environnement) :
gh secret set R2_ACCOUNT_ID --body "<account-id>"
gh secret set R2_ACCESS_KEY_ID --body "<access-key-id>"
gh secret set R2_SECRET_ACCESS_KEY --body "<secret-access-key>"
3 variables GCP comme repository variables (pas environment-scoped) :
gh variable set GCP_PROJECT --body "trade-496613"
gh variable set GCP_WIF_PROVIDER --body "projects/912181505110/locations/global/workloadIdentityPools/github/providers/github"
gh variable set GCP_SA_EMAIL --body "github-deploy@trade-496613.iam.gserviceaccount.com"
Pourquoi repo-level pour les vars GCP plutôt que ré-utiliser le scope production : le scope production a un required reviewer (légitime pour deploy.yml = acte conscient) ; un cron nightly de backup qui pause à 4h du matin en attendant qu'on clique « Approve » ne tient pas. Les 3 identifiers GCP sont publics (pas des secrets, juste des pointeurs), donc no-risk à les exposer au repo level. Les jobs qui déclarent environment: production (comme deploy.yml) continuent de lire en priorité la version env-scoped, les jobs sans environment (comme backup-postgres.yml) lisent la repo-level. Coexistence propre.
Vérifier : gh secret list et gh variable list doivent montrer respectivement les 3 secrets et les 3 vars.
3. Grant secretmanager.secretAccessor au SA de deploy
Le SA github-deploy@ (utilisé par WIF) a run.admin + artifactregistry.writer mais pas d'accès aux secrets — seul portfolioai-runtime@ les a. Octroyer un accès en lecture sur supabase-db-url au deploy SA :
gcloud secrets add-iam-policy-binding supabase-db-url \
--member="serviceAccount:github-deploy@trade-496613.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor" \
--project=trade-496613
Vérification :
gcloud secrets get-iam-policy supabase-db-url --project=trade-496613
# doit lister les 2 SAs : portfolioai-runtime + github-deploy
Principe du moindre privilège respecté : le binding est per-secret, pas project-level.
github-deploy@n'a accès qu'àsupabase-db-url, pas aux 3 autres secrets (google-oauth-client-id,google-oauth-client-secret,app-admin-emails) qui restent réservés au runtime SA.
4. Smoke test
gh workflow run backup-postgres.yml
gh run watch
Attendu : run vert en ~1-2 min. Vérifier côté R2 :
aws s3 ls s3://portfolioai-backups/ \
--endpoint-url "https://8f2780696b5e520f85b5fc80413c4c3f.r2.cloudflarestorage.com" \
--profile portfolioai-r2
# doit lister 1 fichier backup-2026-MM-DDTHH-MM-SSZ.sql.gz
(Le profile portfolioai-r2 peut être configuré localement via aws configure --profile portfolioai-r2 avec les mêmes creds R2 — pratique pour les restore drills.)
Restore drill (trimestriel)
Un backup non-testé est un backup qui n'existe pas. Tous les 3 mois, valider que la chaîne pg_dump → R2 → pg_restore tient end-to-end.
- Récupérer le dernier backup depuis R2 :
LATEST=$(aws s3 ls s3://portfolioai-backups/ --endpoint-url ... | sort | tail -1 | awk '{print $4}') aws s3 cp "s3://portfolioai-backups/$LATEST" "./$LATEST" --endpoint-url ... gunzip "$LATEST" - Provisionner une instance Postgres vide ailleurs :
- Neon free tier :
neon.tech→ New project → région East US → noterDATABASE_URL - OU local :
docker run --rm -d -p 5433:5432 -e POSTGRES_PASSWORD=restore -e POSTGRES_DB=portfolioai_restore postgres:16 - Restore :
psql "<RESTORE_DATABASE_URL>" < backup-2026-MM-DDTHH-MM-SSZ.sql - Sanity check : ouvrir un psql sur la cible et vérifier que les tables connues existent et contiennent des rows :
\dt -- liste les tables SELECT count(*) FROM app_user; -- ≥ 1 (ton compte au moins) SELECT count(*) FROM ticker_narrative_snapshot; SELECT email, role FROM app_user; - Teardown : drop la base Neon (free tier) ou stop le container Docker — pas besoin de garder le clone.
- Noter : date du drill + outcome dans une nouvelle entrée
docs/projet/journal-livraisons.md—Restore drill <date>→ ✅ ou ❌ avec diagnostic.
Follow-ups (hors v1)
| Item | Pourquoi | Effort |
|---|---|---|
| Alert si dernier dump > 36h | Si le runner cron casse silencieusement (e.g. cron syntax invalidé après un edit), on ne le sait pas avant le prochain restore drill. Un 2e workflow scheduled checke aws s3 ls --query 'reverse(sort_by(Contents,&LastModified))[0]' et gh issue create si trop vieux. |
~30 min |
| Encryption client-side | R2 chiffre at-rest par défaut. Mais si on ouvre un jour le repo public, les R2 creds dans GitHub Secrets restent privés, donc rien ne change. Sauf si on veut zero-trust côté Cloudflare : on gpg --encrypt le dump avec une passphrase qui ne touche jamais le runner. Overkill v1. |
~1 h |
| Cross-region replication | R2 a built-in availability multi-PoP, donc le bucket survit à une panne régionale Cloudflare. Mais si Cloudflare entier disparaît : copier le dernier dump nightly vers un 2e provider (e.g. Backblaze B2). Overkill v1. | ~1 h |
| Restore drill automatisé | Au lieu d'un drill manuel trimestriel, un workflow scheduled mensuel qui spin up un Neon temporaire, restore le dernier dump, run quelques SELECT count(*) sanity, teardown. Plus rigoureux mais demande une API Neon stable + creds. |
~3-4 h |
Backup app_config runtime separately |
La table app_config (Phase 2.5) contient les clés API runtime-editable (Anthropic, Twelve Data, Finnhub). Elles sortent dans le pg_dump standard, donc OK pour le restore — mais ce sont des secrets qui se retrouvent dans le backup. À garder en tête le jour où on ouvre le restore process à un tiers (e.g. infra-as-code shared). |
— |
Pièges rencontrés au 1er setup (2026-05-18)
Notés pour épargner du debug à l'avenir si on rejoue le setup (nouveau projet, rotation full creds, etc.).
- Cloudflare R2 affiche 4 champs après la création du token, et 3 d'entre eux ne servent pas à l'auth S3 :
Token value(~40 chars) → API Cloudflare directe, pas S3 — ne PAS l'utiliserAccess Key ID(32 chars hex) → c'est celui-là pourR2_ACCESS_KEY_IDSecret Access Key(64 chars hex) → c'est celui-là pourR2_SECRET_ACCESS_KEYEndpoint URL→ l'Account ID = le sous-domaine, déjà capturé dansR2_ACCOUNT_ID
Le symptôme d'un mauvais paste : le step Upload to Cloudflare R2 fail avec InvalidArgument: Credential access key has length N, should be 32 côté aws s3 cp. Re-roll le token dans Cloudflare R2 si tu as paumé les valeurs, c'est gratuit.
- Les vars
${{ vars.GCP_* }}sont vides au runtime si le job ne déclare pasenvironment: production— elles vivent dans le scopeproductionhistoriquement, pas au repo level. Le symptôme :google-github-actions/auth failed with: the GitHub Action workflow must specify exactly one of "workload_identity_provider" or "credentials_json". Fix :gh variable set GCP_PROJECT/GCP_WIF_PROVIDER/GCP_SA_EMAIL --body "..."au repo level (voir section 2 du Setup). Pas de risque sécu, ce sont des identifiers publics.
Liens
- Workflow source :
.github/workflows/backup-postgres.yml - Release process (complément côté deploy) :
release-process.md - Plan de déploiement complet :
deploiement.md - Plan de migration sortie :
deploiement.md > §7