← Files mittwaldARCHIVED FILE
skills/mittwald-migrate/playbooks/migrate-postgres.md
6.15 KB · Oct 4, 2026 · 12:16 UTC
# Playbook: Migrate PostgreSQL
**Goal:** move PostgreSQL data from the source into a `library/postgres:<version>` container on the Mittwald stack, with no schema drift and verifiable row parity.
> **There is no managed Postgres on mStudio.** Only MySQL and Redis are managed engines; Postgres always runs as a container in the stack. See [`../references/database-engines.md`](../references/database-engines.md).
Entry condition: Provision phase complete. Target stack is up, `postgresql` service is `running`, the bind-mount `/files/<app>/postgres-dumps/` exists on the project host.
## 0. Pre-flight
- Confirm target Postgres major version **matches the source** (`SELECT version();` on each). Cross-major migrations need a `pg_upgrade` step or a logical dump that's beyond this playbook.
- Capture the **list of extensions** in use on the source. Pitfall #6: the `library/postgres` image lacks many managed-Postgres extensions; non-load-bearing ones produce noisy errors that are not fatal.
- Confirm the target DB is **expected to be empty**. Pitfall #7: the container's first start created an empty DB you'll drop and recreate.
## 1. Freeze writes on the source
The downtime window opens here. Take the source app offline so the dump is consistent:
| Source | Action |
|---|---|
| Kubernetes | `kubectl -n <ns> scale deploy/<app> --replicas=0` (also any worker deploys) — Pitfall #5 |
| Docker Compose | `docker compose stop app worker` (leave the DB up) |
| systemd | `systemctl stop <app>.service` |
Leave the **DB running** — you still need to dump from it.
## 2. The streaming pipeline (common case)
One copy-pasteable command, then a breakdown. Customize the bracketed parts.
```bash
set -Eeuo pipefail
# --- bracketed values --------------------------------------------------------
SRC_DB_HOST=postgresql.source # how to reach source DB
SRC_DB_NAME=appdb
SRC_DB_USER=appuser
TGT_PROJ_SSH='user@account@a-XXXXX@ssh.<host>.project.host'
TGT_DUMP_PATH=/files/myapp/postgres-dumps/appdb-$(date +%Y%m%d-%H%M%S).pgc
# -----------------------------------------------------------------------------
PGPASSWORD="$SRC_DB_PW" \
pg_dump \
-h "$SRC_DB_HOST" -U "$SRC_DB_USER" -d "$SRC_DB_NAME" \
-F c -Z 6 --no-owner --no-acl \
| ssh "$TGT_PROJ_SSH" "cat > $TGT_DUMP_PATH"
```
**Breakdown.**
- `set -Eeuo pipefail` — Pitfall #8. Without it, a `pg_dump` failure mid-stream is invisible.
- `pg_dump -F c` — custom format. Compressed, restorable with `pg_restore`, allows selective restore.
- `-Z 6` — gzip level 6 inside the dump. Higher = smaller, slower; 6 is the well-trodden default.
- `--no-owner --no-acl` — strip ownership and ACL metadata. The target user is `appuser`, not the source's, so re-applying ownership would error.
- `| ssh … "cat > …"` — stream straight into a file on the project host bind-mount. No intermediate disk on your laptop.
- Bind-mount path under `/files/myapp/postgres-dumps/` — Pitfall #4. Make sure the compose layout mounted this path **into** the postgres container as well (`/files/myapp/postgres-dumps:/dumps:ro` is a common choice).
**Variants.**
- **Source is Kubernetes** with no direct DB access from your laptop: `kubectl exec` into a helper pod and dump from there:
```bash
kubectl -n <ns> exec -i deploy/db-helper -- \
pg_dump -h postgresql -U appuser -d appdb -F c -Z 6 --no-owner --no-acl \
| ssh "$TGT_PROJ_SSH" "cat > $TGT_DUMP_PATH"
```
- **Source is another mStudio project**: `ssh source-project-ssh 'pg_dump …' | ssh target-project-ssh 'cat > …'`.
- **Want a progress bar**: insert `| pv -terab |` between dump and ssh — Pitfall #9, optional.
## 3. Restore on the target
SSH into the `postgresql` **container** (Pitfall #3: Container-SSH, not Project-Host-SSH):
```bash
ssh "user@account@<postgresql-container-shortId>@ssh.<host>.project.host"
```
Then:
```bash
# inside the postgres container
DUMP=/dumps/appdb-YYYYMMDD-HHMMSS.pgc # path as seen from inside the container
# 1) drop+recreate the target DB (Pitfall #7)
psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS appdb;"
psql -U postgres -d postgres -c "CREATE DATABASE appdb OWNER appuser;"
# 2) restore
pg_restore \
-U postgres -d appdb \
--no-owner --no-acl \
-j 4 \
"$DUMP" 2>&1 | tee /dumps/restore.log
```
`-j 4` parallelizes restore jobs. Bump on bigger DBs; lower if the container is small.
Expect non-fatal errors on extensions and roles. Read them (Pitfall #6) before reacting.
## 4. Post-restore housekeeping
```sql
-- inside psql -U postgres -d appdb
ANALYZE; -- refresh planner stats
REASSIGN OWNED BY postgres TO appuser; -- if any objects landed under postgres
ALTER DATABASE appdb OWNER TO appuser;
```
If the source used `SERIAL` / `IDENTITY` sequences, no action needed — `pg_dump` ships sequence values. If it used a custom sequence-management pattern, verify the next-value matches source.
## 5. Verify
**Row counts (authoritative, Pitfall #13):**
```sql
-- run on both source and target, diff the results
SELECT schemaname, relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY schemaname, relname;
```
Identical → done. Mismatch → investigate before proceeding.
**Spot-check critical tables:**
```sql
SELECT COUNT(*), MAX(updated_at) FROM <critical_table>;
```
Run on both sides, compare.
**Application-level smoke test:** save for the Verify phase against `<shortId>.project.space` (Pitfall #17).
## 6. Rollback paths
- Before restore: deleting the dump file under `/files/.../postgres-dumps/` is harmless; source is still serving (just with app stopped — restart it).
- After a bad restore: drop+recreate `appdb` and re-run from step 3. Dump file is intact.
- After full cutover regret: see [`rollback.md`](rollback.md).
## Pitfalls referenced in this playbook
- #3 SSH modes (Container-SSH for restore, Project-Host-SSH for dump-file landing)
- #4 Bind-mount required for `/dumps`
- #5 Scale source app to 0 (if K8s with RWO PVCs)
- #6 Postgres extension errors are usually non-fatal
- #7 Drop+recreate the initial empty DB
- #8 `set -Eeuo pipefail`
- #9 `pv` optional
- #13 Verify with `n_live_tup`, not bytes
- #17 Smoke test on default domain
SHA-256: 8b357d8e1ca16d2a0973cd83fc117c11087dc19961a89fcace28473e4afd0939