Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ target/
profile_default/
ipython_config.py

# Docu

Coderr_Checklist.md
Coderr_CLAUDE.md

# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
Expand Down
221 changes: 194 additions & 27 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ Warum umgestellt wurde, steht in Abschnitt 7.
/etc/systemd/system/gunicorn-coderr.service Dienstdefinition
/etc/ssh/sshd_config.d/00-hardening.conf SSH-Absicherung
/usr/local/bin/backup-coderr.sh tägliche Sicherung
/var/backups/coderr/ nächtliche Sicherungen, 14 Tage
/home/benni/backups/coderr/ Sicherungen vor jedem Deploy, letzte 10
/swapfile 2 GB Swap
```

Expand Down Expand Up @@ -788,14 +790,37 @@ server {

### 5.1 Code-Änderung ausrollen

Seit dem 14.09.2026 automatisch. Ein Push auf `main`, also ein gemergter
Pull Request, startet in GitHub Actions den Workflow `CI/CD`. Sind `Lint`
und `Tests` grün, schickt der Job `Deploy` das Skript `deploy/deploy.sh`
per SSH an den Server. Das Skript

1. bricht ab, wenn auf dem Server versionierte Dateien geändert wurden,
2. sichert die Datenbank nach `~/backups/coderr/` (siehe 5.5),
3. setzt den Code mit `git merge --ff-only` auf genau den geprüften Commit,
4. führt `pip install`, `check`, `migrate` und `collectstatic` aus,
5. lädt Gunicorn per `HUP` neu, ohne laufende Anfragen abzubrechen.

Danach prüft der Job von außen, ob `/api/base-info/` JSON und eine
statische Datei CSS liefert.

Schritt 4 läuft bei jedem Deploy, auch ohne Änderung. Scheitert ein
Deploy nach dem Merge, holt **Re-run jobs** ihn deshalb vollständig nach.

Nur wenn GitHub Actions nicht verfügbar ist, von Hand vom Arbeitsrechner
in **Git Bash** (PowerShell 5.1 kennt die Umleitung mit `<` nicht), im
Repository:

```bash
git fetch
ssh vps "bash -s -- $(git rev-parse origin/main)" < deploy/deploy.sh
```

Bekommt Gunicorn selbst eine neue Version, reicht `HUP` nicht. Dann
einmal von Hand:

```bash
cd /var/www/coderr/backend
git pull
.venv/bin/pip install -r requirements.txt # nur bei neuen Paketen
.venv/bin/python manage.py migrate # nur bei neuen Migrationen
.venv/bin/python manage.py collectstatic --noinput
sudo systemctl restart gunicorn-coderr
sudo systemctl status gunicorn-coderr --no-pager
```

### 5.2 Logs ansehen
Expand Down Expand Up @@ -845,13 +870,24 @@ Der Befehl ist wiederholbar. Vorhandene Datensätze werden aktualisiert,
nicht doppelt angelegt. Er läuft in einer Transaktion, bei einem Fehler
bleibt der vorherige Zustand erhalten.

### 5.5 Datenbank sichern
### 5.5 Datenbank sichern und wiederherstellen

Es gibt zwei Sicherungen, beide als gepacktes SQL aus `pg_dump`.

| | nächtlich | vor jedem Deploy |
|---|---|---|
| Auslöser | `coderr-backup.timer`, täglich 03:30 UTC | `deploy/deploy.sh`, vor dem Merge |
| Ablage | `/var/backups/coderr/db-<Datum>.sql.gz` | `~/backups/coderr/<Datum>_<Kurz-Hash>.sql.gz` |
| erstellt als | `postgres`, über `/usr/local/bin/backup-coderr.sh` | `coderr`, Passwort aus der `.env` |
| Aufbewahrung | 14 Tage | die letzten 10 |
| Media-Ordner | ja, als `media-<Datum>.tar.gz` | nein |

**Der Kurz-Hash im Dateinamen ist der Commit, der ausgerollt werden
sollte, nicht der, zu dem die Sicherung passt.** Die Sicherung entsteht
vor dem Merge und enthält den Stand davor. Genau diese Datei braucht man,
wenn ein Deploy mit Migration zurückgenommen werden muss.

Läuft automatisch täglich um 03:30 Uhr Serverzeit (UTC) über
`/usr/local/bin/backup-coderr.sh`, ausgelöst von
`coderr-backup.timer`. Gesichert werden die Datenbank und der
`media`-Ordner, Ablage unter `/var/backups/coderr/`, Aufbewahrung
14 Tage.
Die nächtliche Sicherung:

```bash
# Status und naechster Lauf
Expand All @@ -862,10 +898,6 @@ sudo journalctl -u coderr-backup -n 30 --no-pager

# Sofort ausfuehren
sudo /usr/local/bin/backup-coderr.sh

# Sicherung einspielen
gunzip -c /var/backups/coderr/db-2026-07-27_0330.sql.gz \
| sudo -u postgres psql coderr
```

Der Timer nutzt `Persistent=true`, ein wegen Neustart verpasster Lauf
Expand All @@ -877,6 +909,134 @@ gesamten Servers. Ein manueller Snapshot vor riskanten Änderungen ist im
hPanel kostenlos möglich, allerdings nur einer gleichzeitig und mit
einem Tag Haltbarkeit.

#### Einspielen proben

`deploy/restore_probe.sh` spielt eine Sicherung in die Wegwerf-Datenbank
`coderr_restore_test` ein und löscht sie am Ende wieder, auch nach einem
Fehler. Dazwischen vergleicht es die Zeilenzahl jeder Tabelle mit der
echten Datenbank, prüft, ob eine ID-Sequenz hinter der höchsten ID liegt,
und fragt Django mit `migrate --plan`, ob der ausgerollte Code an der
Kopie noch etwas migrieren würde. Die echte Datenbank wird nur gelesen.
`sudo` ist nicht nötig, weil `coderr` das Recht `CREATEDB` hat
(Abschnitt 3.8).

Vom Arbeitsrechner, in PowerShell im Repository:

```powershell
scp deploy/restore_probe.sh vps:restore_probe.sh
ssh vps "bash ~/restore_probe.sh ~/backups/coderr/<datei>.sql.gz"
ssh vps "bash ~/restore_probe.sh /var/backups/coderr/<datei>.sql.gz"
ssh vps "rm ~/restore_probe.sh"
```

So wird das Ergebnis gelesen:

- **Unter `messages` steht nichts.** Das Einspielen läuft mit
`ON_ERROR_STOP`, schon der erste Fehler bricht ab und steht dort.
- **`<- differs` ist nicht automatisch ein Fehler.** Die Live-Datenbank
ist neuer als die Sicherung. Ein Unterschied muss sich mit dem erklären
lassen, was seitdem passiert ist.
- **Unter `Sequences` steht nichts.** Eine Sequenz unter der höchsten ID
lässt den nächsten neuen Datensatz an einem doppelten Schlüssel
scheitern, und das zeigt keine Zeilenzahl.
- **`No planned migration operations`.** Das gilt nur, solange seit der
Sicherung keine Migration ausgerollt wurde.

Geprobt am 15.09.2026 mit beiden Arten: beide in einer Sekunde und ohne
Meldung eingespielt, Sequenzen in Ordnung, keine offene Migration. Die
Deploy-Sicherung vom 14.09. abends hatte eine Kontaktnachricht und einen
Cache-Eintrag weniger als die Live-Datenbank, die nächtliche vom 15.09.
stimmte in allen 17 Tabellen überein. Der Media-Ordner war nicht Teil der
Probe.

#### Notfall: Datenbank aus einer Sicherung zurückholen

Für einen Deploy, dessen Migration zurückgenommen werden muss. **Nie in
die laufende Datenbank `coderr` einspielen.** `pg_dump` schreibt reines
SQL ohne vorheriges Löschen. In eine gefüllte Datenbank eingespielt,
scheitern die Tabellen an „existiert bereits“, die Daten an doppelten
Schlüsseln, und übrig bleibt ein Mischstand.

Stattdessen entsteht die Kopie neben der echten Datenbank, und beim
Tausch bleibt die kaputte als `coderr_defekt` zum Vergleich liegen.
**Geprobt ist das Einspielen bis Schritt 3, der Tausch ab Schritt 4 noch
nicht** (Abschnitt 8).

**1. Sicherung wählen.** Die Datei, deren Kurz-Hash der gescheiterte
Commit ist:

```bash
ls -1t ~/backups/coderr/
```

**2. Zugang als `coderr` setzen.** Das Passwort kommt aus der `.env` und
erscheint nicht auf dem Bildschirm:

```bash
cd /var/www/coderr/backend
export PGHOST=localhost PGUSER=coderr
export PGPASSWORD="$(grep -m1 '^DB_PASSWORD=' .env | cut -d= -f2- | tr -d "\r\"'")"
```

**3. In eine neue Datenbank einspielen**, während Coderr noch läuft:

```bash
createdb coderr_neu
gunzip -c ~/backups/coderr/<datei>.sql.gz | psql -X -q -v ON_ERROR_STOP=1 -d coderr_neu
```

Bricht das ab: `dropdb coderr_neu`. Die echte Datenbank ist unberührt.

**4. Anhalten und den Code zurücksetzen.** Ab hier sind Coderr und das
Kontaktformular des Portfolios offline. Der Commit vor dem Deploy steht
im Protokoll des Deploy-Jobs in der Zeile `Server:`, auf dem Server im
Reflog als Eintrag vor dem `merge`:

```bash
sudo systemctl stop gunicorn-coderr
git reflog -5
git reset --hard <commit vor dem merge>
```

**5. Die Datenbanken tauschen.** Umbenennen darf `coderr` als Besitzer
mit `CREATEDB`. Es scheitert, solange noch jemand mit der Datenbank
verbunden ist:

```bash
psql -X -d postgres -c 'ALTER DATABASE coderr RENAME TO coderr_defekt'
psql -X -d postgres -c 'ALTER DATABASE coderr_neu RENAME TO coderr'
```

**6. Prüfen und starten:**

```bash
.venv/bin/pip install -r requirements.txt
.venv/bin/python manage.py migrate --plan
.venv/bin/python manage.py collectstatic --noinput
sudo systemctl start gunicorn-coderr
```

`migrate --plan` muss `No planned migration operations` melden, bevor
Gunicorn startet. Sonst passen Code und Sicherung nicht zusammen.

**7. Danach.** `unset PGPASSWORD`. Den gescheiterten Commit auf GitHub
per Revert-PR zurücknehmen. **Den gescheiterten Deploy-Job nicht mit
Re-run jobs neu starten**, er würde denselben Commit samt Migration
wieder ausrollen. `coderr_defekt` erst mit `dropdb coderr_defekt`
löschen, wenn klar ist, dass daraus nichts mehr gebraucht wird.

**Ohne Migration** braucht es keine Sicherung und keine Ausfallzeit:

```bash
cd /var/www/coderr/backend
git reset --hard <commit vor dem merge>
.venv/bin/pip install -r requirements.txt
.venv/bin/python manage.py collectstatic --noinput
kill -HUP "$(systemctl show -p MainPID --value gunicorn-coderr)"
```

Auch dann gilt Schritt 7: Revert-PR, kein Re-run.

### 5.6 Kontaktformular

Das Formular des Portfolios postet an `/api/contact/`. Zuständig ist
Expand Down Expand Up @@ -1077,20 +1237,27 @@ Für die Zukunft: Secret Key ab dem ersten Commit in die `.env`.

## 8. Offene Punkte

- [ ] Wiederherstellung einer Sicherung einmal proben, mit einer
Kopie der Datenbank und nicht mit der echten
- [ ] Entscheiden, ob `benjaminblarr.dev` über den 06.02.2027 hinaus
verlängert wird. Die automatische Verlängerung steht derzeit auf
aus. Solange die Domain lebt, funktionieren alte Links aus
Bewerbungen und von LinkedIn über die Weiterleitung weiter.
Läuft sie aus, laufen diese Links ins Leere und der Name wird
für jeden frei
- [ ] Alte DNS-Einträge in der `.dev`-Zone aufräumen, sobald über den
Punkt darüber entschieden ist: `A ftp` auf den alten
- [ ] Das Umbenennen aus 5.5, Schritt 5, einmal proben. Das geht ohne
Ausfallzeit an einer Kopie: einspielen, umbenennen, löschen
- [ ] Alte DNS-Einträge in der `.dev`-Zone aufräumen, seit der
Entscheidung vom 15.09.2026 freigegeben: `A ftp` auf den alten
Webhosting-Server, die drei `hostingermail-*._domainkey`, die
beiden `MX`, `autodiscover`, `autoconfig` und der SPF-Eintrag.
**Nicht anfassen:** `A @`, `AAAA @` und `CNAME www`, die zeigen
auf den VPS und tragen die Weiterleitung
auf den VPS und tragen die Weiterleitung bis zum Ablauf
- [ ] Ab dem 09.02.2027, nach dem Ablauf: den Nginx-Block
`benjaminblarr-dev` (Abschnitt 4.6) samt Verweis in
`sites-enabled` und das Zertifikat für `benjaminblarr.dev` vom
Server entfernen. Sonst versucht certbot weiter, ein Zertifikat
für eine Domain zu erneuern, die es nicht mehr gibt

### Erledigt am 15.09.2026

- [x] Einspielen beider Sicherungsarten in eine Kopie der Datenbank
geprobt, fehlerfrei, siehe 5.5 und `deploy/restore_probe.sh`
- [x] Entschieden: `benjaminblarr.dev` wird nicht verlängert und läuft
am 06.02.2027 aus. Alte Links aus Bewerbungen und von LinkedIn
führen danach ins Leere, und der Name wird für jeden frei

### Erledigt am 29.07.2026

Expand Down
113 changes: 113 additions & 0 deletions deploy/restore_probe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Restore probe for the Coderr database. Loads one backup into a throwaway
# database, checks it against the live database and drops it again.
# The live database is only read, never written.
# ssh vps "bash -s -- <path to .sql.gz>" < restore_probe.sh
set -euo pipefail

APP_DIR=/var/www/coderr/backend
TEST_DB=coderr_restore_test

backup="${1:-}"
if [ ! -r "$backup" ]; then
echo "Usage: restore_probe.sh <readable .sql.gz backup>" >&2
exit 1
fi

# Never add "set -x" to this script: it would print the database password.
env_value() {
local value
value="$(grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\r\"'")" || true
if [ -z "$value" ]; then
echo "$1 is missing in .env" >&2
exit 1
fi
printf '%s' "$value"
}

cd "$APP_DIR"
live_db="$(env_value DB_NAME)"
PGHOST="$(env_value DB_HOST)"
PGPORT="$(env_value DB_PORT)"
PGUSER="$(env_value DB_USER)"
PGPASSWORD="$(env_value DB_PASSWORD)"
export PGHOST PGPORT PGUSER PGPASSWORD

if [ "$live_db" = "$TEST_DB" ]; then
echo "The test database name equals the live database, aborting." >&2
exit 1
fi

sql() {
psql -X -At -v ON_ERROR_STOP=1 -d "$1" -c "$2"
}

if [ "$(sql "$live_db" "select count(*) from pg_database where datname = '$TEST_DB'")" != "0" ]; then
echo "$TEST_DB already exists and is left untouched, aborting." >&2
exit 1
fi

created=0
errors="$(mktemp)"
cleanup() {
rm -f "$errors"
if [ "$created" = 1 ]; then
dropdb --if-exists "$TEST_DB" && echo "--- Dropped $TEST_DB ---"
fi
}
trap cleanup EXIT

echo "Backup: $backup ($(du -h "$backup" | cut -f1))"
echo "Live: $live_db, PostgreSQL $(sql "$live_db" 'show server_version')"

createdb "$TEST_DB"
created=1

started=$SECONDS
if ! gunzip -c "$backup" | psql -X -q -v ON_ERROR_STOP=1 -d "$TEST_DB" >/dev/null 2>"$errors"; then
echo "--- Restore FAILED ---"
cat "$errors"
exit 1
fi
echo "--- Restore finished in $((SECONDS - started)) s, messages below (empty is good) ---"
cat "$errors"

# Exact counts: n_live_tup in pg_stat_user_tables is only an estimate.
count_rows() {
sql "$1" "select c.relname || ' ' || (xpath('/row/n/text()', query_to_xml(
format('select count(*) as n from %I.%I', n.nspname, c.relname), false, true, '')))[1]
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname = 'public'" | LC_ALL=C sort
}

echo "--- Row counts: table, backup, live ---"
LC_ALL=C join -a1 -a2 -e missing -o 0,1.2,2.2 \
<(count_rows "$TEST_DB") <(count_rows "$live_db") \
| awk '{ printf "%-45s %8s %8s%s\n", $1, $2, $3, ($2 == $3 ? "" : " <- differs") }'

# A sequence below the highest id makes the next insert fail with a
# duplicate key, which a row count alone would never reveal.
echo "--- Sequences behind their table (empty is good) ---"
sql "$TEST_DB" "
select t.tbl || '.' || t.col || ' max=' || t.max_id || ' seq=' || coalesce(t.seq_value::text, 'unused')
from (
select format('%I.%I', n.nspname, c.relname) as tbl,
a.attname as col,
(xpath('/row/m/text()', query_to_xml(
format('select max(%I) as m from %I.%I', a.attname, n.nspname, c.relname),
false, true, '')))[1]::text::bigint as max_id,
pg_sequence_last_value(
pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname)::regclass
) as seq_value
from pg_attribute a
join pg_class c on c.oid = a.attrelid
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and a.attnum > 0 and not a.attisdropped
and pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname) is not null
) t
where coalesce(t.max_id, 0) > coalesce(t.seq_value, 0)"

# load_dotenv() does not override variables that are already set, so this
# points Django at the restored copy while everything else comes from .env.
echo "--- Migrations the deployed code would still apply to the copy ---"
DB_NAME="$TEST_DB" .venv/bin/python manage.py migrate --plan