Backups
Twee onafhankelijke datasets. Een restore van alleen Postgres levert een site op waarvan elke afbeelding 404’t — media-bytes staan in object storage, niet in de database.
Postgres (Coolify scheduled backups)
Section titled “Postgres (Coolify scheduled backups)”S3-storage toevoegen (R2/B2 bucket platform-backups), region auto voor R2.
Twee planningen op dezelfde Postgres-resource, want de toggle bepaalt het dumpformaat:
Backup All Databases AAN |
UIT + Databases To Backup |
|
|---|---|---|
| Commando | pg_dumpall | gzip |
pg_dump --format=custom --no-acl --no-owner per database |
| Bestand | één pg-dump-all-<unix-ts>.gz |
pg-dump-<db>-<unix-ts>.dmp per database |
| Waarvoor | vangnet: mist nooit een nieuwe tenant | restore-bron: pg_restore leest dit direct |
0 3 * * *, AAN. Tenant-databases worden handmatig aangemaakt; alleen deze planning pakt een nieuwe tenant automatisch mee.30 3 * * *, UIT, metpostgres,tenant_<id>inDatabases To Backup. Bij elke nieuwe tenant deze lijst bijwerken — de planning uit stap 1 dekt het gat tot dat gebeurt.
Dumps landen op de host onder
/data/coolify/backups/databases/<team-slug>-<team-id>/<resource-naam>-<uuid>/.
3. Backup All Databases, Databases To Backup en de retentie staan niet in het aanmaakvenster, alleen in het edit-scherm van een opgeslagen planning. Bij aanmaken vult Coolify databases_to_backup met de postgres_db van de resource (postgres) en zet dump_all op false: een niet-bijgewerkte planning back-upt dus alleen postgres.
4. Het Timezone-veld in dat scherm is read-only; het toont de servertijdzone (server_settings.server_timezone) en BackupEdit::submit() schrijft het niet. Cron draait dus in UTC tenzij je de tijdzone op de server zelf wijzigt. UTC houden is voor backups te verdedigen: geen dubbele of overgeslagen run bij de zomertijdovergang.
5. Retentie: Number of backups to keep = 7 onder Local Backup Retention, Days to keep backups = 35 onder S3 Storage Retention. 0 betekent onbeperkt (bootstrap/helpers/databases.php: zijn amount, days én max storage alle drie 0, dan verwijdert Coolify niets), en 0 is de default op alle zes velden. Zet daarnaast een lifecycle-regel van 35 dagen op de bucket zelf, zodat één vergeten Coolify-instelling geen PII eeuwig laat staan.
5. Controleer met de hand dat de eerste dump tenant_<id> echt bevat.
Een cluster-dump uit pg_dumpall is géén restore-bron voor één tenant: hij bevat
alle databases plus CREATE ROLE/CREATE DATABASE/\connect. Door psql duwen
naar één database zet dus het hele cluster terug.
Verificatie maandelijks: scripts/verify-backup.sh /path/to/dumps tenant_acme. Het
script kiest de per-database .dmp boven een nieuwere cluster-.gz, controleert de
custom-format-TOC met pg_restore --list, en slaat de restore-probe met een luide
SKIP over als er alleen een cluster-dump ligt.
Media (host-cron + rclone)
Section titled “Media (host-cron + rclone)”Coolify dekt object storage niet. R2 heeft geen versioning — een foute delete is onherstelbaar zonder eigen replica.
# scripts/backup-media.sh — draait op de host, niet in de app-containerrclone sync r2:platform-media b2:platform-media-backup \ --backup-dir b2:platform-media-archive/$(date -u +%F)--backup-dir parkeert verwijderde/overschreven objecten. Cross-provider (R2 → B2) zodat één gecompromitteerd account niet beide meeneemt. rclone.conf blijft op de host, buiten de repo.
Zoals ingericht op de VPS
Section titled “Zoals ingericht op de VPS”- Repo als echte clone op
/root/okhema, read-only deploy key/root/.ssh/id_ed25519_okhema_deploy(viacore.sshCommand). Bijwerken isgit -C /root/okhema pull. - rclone remotes
r2(S3-backend,provider=Cloudflare) enb2(native backend) in/root/.config/rclone/rclone.conf. - Cron:
15 3 * * *UTC naar/var/log/media-backup.log, logrotate 5 weken (/etc/logrotate.d/media-backup). - Het script schrijft
/var/lib/media-backup/last-successalleen ná een geslaagde sync. De ouderdom van dat bestand is dus het eerlijke antwoord op “wanneer is de replica echt bijgewerkt?”.
Vier dingen die hier misgaan:
- Het R2-token voor de bron moet Object Read only zijn.
rclone syncschrijft niets naar de bron; met een read-write token kan een gecompromitteerde VPS je media wissen. - Zet géén leeftijdsregel op de replica-bucket. Die is een levende spiegel; een 35-dagenregel wist objecten die nog live op de site staan. De 35 dagen hoort alleen op de archive-bucket.
- B2-bucketnamen zijn wereldwijd uniek over alle accounts, dus een naam kan bezet zijn door een vreemde.
- Een B2 application key is te beperken tot één bucket of tot alle, niet tot twee. Voor replica plus archive is de scope dus “All”; houd dat account daarom uitsluitend voor backups en gebruik niet de master key.
Verificatie: rclone check r2:platform-media b2:platform-media-backup --download vergelijkt bytes. Zonder --download valt rclone terug op grootte, want R2 (MD5/ETag) en B2 (SHA1) hebben geen gemeenschappelijke hash.
Openstaand: geen alarmering. Faalt een cronjob, of draait cron helemaal niet
meer, dan meldt niets dat. De stamp-bestanden onder /var/lib/media-backup en
/var/lib/dump-verify maken het controleerbaar, maar alleen als iemand kijkt.
Bewust doorgeschoven naar taak 4.4 (statuspagina; noemt mislukte backups
letterlijk), met 2.2 en 2.3 als plumbing. Een statuspagina die de ouderdom van deze
stamp-bestanden uitleest vangt ook stilte op — cron die niet meer draait of een VPS
die uit ligt. Een faalmail uit het script vangt juist dat geval niet.
Operator-backup (per tenant, voor restore-rehearsal)
Section titled “Operator-backup (per tenant, voor restore-rehearsal)”DATABASE_URL=… \ pnpm --filter @platform/ops run backup -- --tenant pilot --out ./backupsLevert:
backups/<tenantId>/<UTC-timestamp>/ meta.json postgres.dump # pg_dump -Fc --no-owner --no-privileges media/<storage key…> media-manifest.json checksums.txtGebruik postgres:18-alpine via Docker voor pg_dump/pg_restore zodat de client-major overeenkomt met de server (meta.json legt de versie vast). Een oudere client (bijv. 16) weigert een dump van Postgres 18.
De rijtellingen in meta.json komen uit dezelfde snapshot als de dump: backup opent een repeatable read-transactie, exporteert de snapshot met pg_export_snapshot() en geeft die als --snapshot aan pg_dump mee. Zonder dat werden de tellingen ná de dump geteld en meldde verify --expect op een site met verkeer een counts.match-verschil dat op een kapotte backup lijkt (gemeten: 291 gerestaureerde tegen 298 verwachte form_submissions). Gevolg: de transactie blijft open zolang pg_dump loopt, precies zoals pg_dump zelf al doet. Die sessie staat in die tijd wel idle in transaction, dus idle_in_transaction_session_timeout en transaction_timeout moeten ruimer zijn dan de dumpduur. Beide staan in Postgres 18 default op 0 (uit); controleer ze als een grote tenant gaat falen waar hij eerder slaagde.
De media-listing valt buiten die snapshot — een bucket kent geen transacties. Een upload tijdens de listing levert een manifest-item zonder databaserij (op restore een onschuldige orphan); een delete van een object dat wél in de dump zit, laat de restore hard falen op Missing media file for key.
Restore
Section titled “Restore”pnpm --filter @platform/ops run restore -- \ --from ./backups/pilot/<ts> \ --database-url postgresql://… \ --yes- Weigert een database die al een
site-rij heeft, tenzij--allow-nonempty. Met--allow-nonemptydroptpg_restore --clean --if-existsbestaande objecten (plain SQL:DROP SCHEMA public CASCADE+ recreate) voordat de dump wordt ingespeeld. --skip-mediaslaat uploads over en slaat S3/object-checks in de post-verify over.- Autodetecteert
postgres.dump→pg_restore,.sql/.sql.gz→psql, en Coolify’s per-database*.dmp(zelfde custom format). Een cluster-dumppg-dump-all-*.gzwordt op naam geweigerd: die zet het hele cluster terug. - Zonder
meta.json(een kale Coolify-dump) is--skip-mediaverplicht: er is dan geen manifest om media uit te herstellen, en de post-verify toetst wel de structuur maar niet de rijtellingen. - Cross-tenant-guard: elke media-key moet met
${tenantId}/beginnen. - Weigert een
*_verify-doeldatabase zonder--skip-media. Dat is de scratch-DB uit de klantvariant in Operations; de manifest-keys zijn de live keys, dus een media-restore zou de klantobjecten overschrijven.scripts/scratch-verify-db.shmaakt en dropt die database. - Roept automatisch
verify-tenantaan tegenmeta.json. - Laat de doeldatabase een leeg
publicachter.pg_dumpneemt geenCREATE SCHEMA publicop, dus restoren in een database zonder dat schema faalt op élk object. Na eenDROP SCHEMA public CASCADEdusCREATE SCHEMA public.
Na elke restore: herstart de app-container. getDb() cachet pools per URL zonder revalidatie; zonder restart geven verzoeken 500 tegen een dode pool.
Zie Operations voor de generale repetitie en AVG-bewaartermijn, en Restore-rehearsal 12 augustus 2026 voor de uitgevoerde repetitie met gemeten RTO/RPO.