Files
watermaps/deploy/README.md
T
BuTzZ 2064570913
Test and publish container images / test (push) Successful in 2m43s
Test and publish container images / publish (push) Successful in 2m56s
Automate immutable production deployments
2026-07-29 13:00:50 +02:00

11 KiB

Watermaps-Produktion

Dieser Stack hostet die Watermaps-App, die lokalen Fahrrouten sowie eine persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten in Deutschland und den Niederlanden. Die sichtbaren Kartenkacheln bleiben externe Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; App und PostGIS sind nur im internen Docker-Netz erreichbar.

Konfiguration

cp deploy/.env.production.example deploy/.env.production
editor deploy/.env.production

Mindestens WATERMAPS_ACME_EMAIL und WATERMAPS_POSTGRES_PASSWORD müssen angepasst werden. Für das Datenbankpasswort eignet sich ein URL-unabhängiges Hex-Secret:

openssl rand -hex 32

Außerdem müssen WATERMAPS_REGISTRY und WATERMAPS_REGISTRY_OWNER auf die Gitea Container Registry zeigen. Der Hetzner-API-Token gehört nicht in diese Datei. Er bleibt lokal in der ignorierten Datei infra/opentofu/terraform.tfvars (alternativ kann der Provider TF_VAR_hcloud_token lesen).

Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Standardmäßig verwenden die lokalen Deployment-Skripte ~/.ssh/watermaps_deploy_ed25519; alternativ kann sie mit --identity oder über WATERMAPS_SSH_KEY angegeben werden. Der vorbereitete SSH-Benutzer heißt standardmäßig deploy; die privilegierten Installationsschritte laufen über dessen passwortloses sudo.

Gitea Actions und Container Registry

Der Workflow .gitea/workflows/container-images.yml führt Typechecks und Tests aus. Nach einem erfolgreichen Push auf main baut und veröffentlicht er drei OCI-Images unter der vollständigen Git-Commit-SHA:

gitea.incoso.eu/kevin_janssen/watermaps:<commit-sha>
gitea.incoso.eu/kevin_janssen/watermaps-route-data:<commit-sha>
gitea.incoso.eu/kevin_janssen/watermaps-release:<commit-sha>

Das dritte Image ist ein nicht ausführbares Release-Bundle mit Compose-Datei, Nginx-Konfiguration, Systemd-Units, Deployment-Skripten und versionierten Datenbankmigrationen. Sein geprüftes Manifest bindet die exakten Registry-Digests von App und Routingdaten; SHA256SUMS schützt alle Dateien des Bundles. Erst nachdem alle drei Commit-Images veröffentlicht wurden, wird watermaps-release:main als einzelner atomarer Release-Pointer verschoben. App- und Routingdaten-Images werden nie über bewegliche Tags gestartet.

In Gitea müssen Repository Actions aktiviert und ein Docker-fähiger ubuntu-latest-Runner registriert sein. Unter Repository → Settings → Actions → Secrets werden benötigt:

  • REGISTRY_USERNAME: Gitea-Benutzer, dem die Packages gehören
  • REGISTRY_TOKEN: eigener CI-Personal-Access-Token ausschließlich mit write:package

Der separate PAT ist nötig, weil Gitea 1.26 mit dem eingebauten Job-Token noch keine OCI-Pakete veröffentlichen kann. Für Pull Requests werden nur Tests ausgeführt; Registry-Secrets werden dabei nicht verwendet.

Die Secrets lassen sich mit zwei getrennten, lokal ignorierten Token-Dateien konfigurieren:

  • deploy/.gitea-actions-config-token: kurzlebiger Konfigurations-PAT nur mit write:repository, um die Actions-Secrets über die Repository-API zu setzen
  • deploy/.gitea-registry-publish-token: CI-PAT nur mit write:package, der anschließend als REGISTRY_TOKEN in Gitea Actions hinterlegt wird

Beide Dateien enthalten jeweils nur den Token in einer einzelnen Zeile und müssen exakt Dateimodus 600 haben:

chmod 600 \
  deploy/.gitea-actions-config-token \
  deploy/.gitea-registry-publish-token
./deploy/scripts/configure-gitea-actions.sh

Das Skript nutzt den Konfigurations-PAT ausschließlich lokal für den API-Aufruf und speichert ihn ausdrücklich nicht als Actions-Secret. Anschließend sollten beide lokalen Token-Dateien gelöscht werden; Gitea zeigt den gespeicherten Registry-Secret-Wert nicht wieder an.

Deployment

Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den OpenTofu-Output server_ipv4. Es erlaubt ausschließlich einen sauberen, vollständig committeten Git-Stand und überträgt für die Ersteinrichtung nur Bootstrap-Dateien und die getrennte Produktionskonfiguration, nicht den Anwendungsquellcode. Der Server bezieht das kanonische Release anschließend selbst aus der Gitea Registry und prüft, dass es exakt git rev-parse HEAD entspricht.

Pakete des öffentlichen Gitea-Benutzers können ohne Server-Credential gelesen werden. Wird die Registry später privat geschaltet, werden einmalig beziehungsweise nach Tokenwechsel lokale Pull-Zugangsdaten mitgegeben:

WATERMAPS_REGISTRY_USERNAME=kevin_janssen \
WATERMAPS_REGISTRY_TOKEN='<package-read-token>' \
  ./deploy/scripts/upload-and-deploy.sh \
  --identity ~/.ssh/watermaps_deploy_ed25519

Der Token benötigt auf dem Produktionsserver nur package: Read. Er wird per SSH an docker login --password-stdin übergeben, nicht in .env.production, einem Container oder dem Image gespeichert. Docker legt die Anmeldung root-lesbar in seiner lokalen Client-Konfiguration ab. Bei späteren Deployments muss der Token nicht erneut angegeben werden, solange die Anmeldung gültig ist:

./deploy/scripts/upload-and-deploy.sh \
  --identity ~/.ssh/watermaps_deploy_ed25519

Das Serverskript lädt das Release-Manifest, App, den kombinierten Routing-/Feature-Daten-Builder, PostGIS, Nginx und Certbot. App und Builder werden ausschließlich über die im Release gebundenen Digests gestartet. Geordnete SQL-Dateien unter database/migrations/ werden mit watermaps_schema_migrations protokolliert und jeweils in einer Transaktion ausgeführt. Neue Migrationen müssen nach dem Expand/Contract-Prinzip mit dem vorherigen App-Release kompatibel bleiben. Der Produktionsserver benötigt weder Git noch Node/npm oder einen Repository-Checkout.

Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die Niederlande, baut den Fahrroutenindex und importiert daraus die marine_features. Je nach Serverleistung kann dies längere Zeit dauern. App und Nginx werden beim ersten Release erst gestartet, wenn sowohl der Routingindex als auch ein nicht leerer, zur PBF-Prüfsumme passender Bestand an Häfen, Schleusen und Brücken geprüft wurde. Ein HTTP-Healthcheck allein kann damit keine leere Ereignisdatenbank freigeben.

Die produktiven Daten liegen auf dem persistenten Servervolume:

/srv/watermaps-data/local/germany-netherlands-fairways.json
/srv/watermaps-data/local/.marine-features.ready
/srv/watermaps-data/postgres/
/srv/watermaps-data/tmp/

PostGIS veröffentlicht keinen Host-Port. Die App verbindet sich im privaten Compose-Netz; das Passwort wird separat über PGPASSWORD übergeben und muss nicht URL-kodiert in DATABASE_URL dupliziert werden. Auch die temporären, potenziell mehrere Gigabyte großen Filter- und GeoJSON-Dateien des Imports liegen unter tmp/ auf diesem Volume und nicht im Docker-Overlay der Rootdisk; nach jedem Importlauf werden sie entfernt.

Der Upload wartet zuerst auf SSH und den Abschluss von Cloud-init. Anschließend startet und prüft er watermaps-volume-setup.service. Ohne tatsächlich unter /srv/watermaps-data eingehängtes Volume wird kein Download gestartet, damit die großen PBF-Dateien nicht versehentlich auf dem Root-Dateisystem landen. Für den kurzzeitigen Speicherpeak beim kombinierten Indexaufbau richtet der Bootstrap zusätzlich die über WATERMAPS_SWAP_SIZE_GB konfigurierte, persistente Swap-Reserve ein.

Vor dem Livegang antwortet Nginx nur für ACME-Challenges. Alle anderen HTTP-Anfragen erhalten 404.

Rollback

Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten Image-Digests unter /srv/watermaps-runtime/deployments/ gespeichert; sein vollständiges Bundle liegt unter /srv/watermaps-data/releases/<commit>. /opt/watermaps/current wird erst nach Health-, Routen- und Featuretests atomar auf ein neues Bundle umgeschaltet. Scheitert ein Deployment, wird das vorherige unveränderliche Bundle erneut ausgeführt und vollständig geprüft. Die persistente PostGIS-Datenbank bleibt erhalten; Migrationen sind deshalb bewusst vorwärtskompatibel zu gestalten.

Das unmittelbar vorherige Release lässt sich auch manuell aktivieren:

./deploy/scripts/remote-rollback.sh \
  --identity ~/.ssh/watermaps_deploy_ed25519

Oder ein bestimmter, bereits erfolgreich deployter Commit:

./deploy/scripts/remote-rollback.sh \
  --revision 0123456789abcdef0123456789abcdef01234567 \
  --identity ~/.ssh/watermaps_deploy_ed25519

Der Rollback verwendet gespeicherte Digests, nicht einen beweglichen Tag.

DNS und SSL-Livegang

Sobald der OpenTofu-Output bekannt ist, kann folgender DNS-Eintrag gesetzt werden:

A  watermaps.incoso.eu  <server_ipv4>

Nach der DNS-Propagation wird der Livegang lokal ausgelöst:

./deploy/scripts/remote-go-live.sh --identity ~/.ssh/watermaps_deploy_ed25519

Das Serverskript prüft, dass sämtliche A-Records ausschließlich auf die erwartete IPv4 zeigen, testet den Routingindex mit je einer Route in Deutschland und den Niederlanden, prüft den ACME-Webroot, fordert das Zertifikat an und aktiviert erst anschließend HTTPS. HTTP leitet danach auf HTTPS um.

Automatik

bootstrap-server.sh installiert drei systemd-Timer:

  • watermaps-auto-deploy.timer: prüft ausgehend jede Minute watermaps-release:main. Das Revision-Label muss exakt dem aktuellen Gitea-main-Commit entsprechen. Der Server prüft Manifest, Dateiprüfsummen, gebundene Image-Digests und OCI-Revisionen und deployt erst nach einer zweiten Pointer-/Branch-Prüfung. Es ist weder ein eingehender Gitea-Webhook noch ein SSH-Key im Actions-Runner nötig. Aktives, vorheriges und die über WATERMAPS_RELEASE_RETENTION festgelegte Zahl lokaler Releases bleiben erhalten; ältere Watermaps-Artefakte werden gezielt bereinigt. Vor dem ersten Release verwendet ein stabiler Systemd-Einstiegspunkt das Bootstrap-Skript, danach automatisch das atomar aktivierte current. Noch nicht fertige erste Registry-Builds werden dadurch zuverlässig erneut versucht.

  • watermaps-route-update.timer: täglich neue Deutschland- und Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der Feature-Importer aktualisiert beide Länder per Upsert und entfernt verschwundene Datensätze erst, nachdem beide Importe vollständig waren; dabei werden ausschließlich OSM-Zeilen bereinigt. EuRIS- und Website-Anreicherungen bleiben erhalten.

  • watermaps-certbot-renew.timer: zweimal täglich Certbot-Prüfung mit anschließendem Nginx-Reload.

Ein vollständiger manueller Feature-Neuimport lässt sich über die Option --force-marine erzwingen:

/opt/watermaps/current/deploy/scripts/update-route-data.sh --force-marine

WATERMAPS_REBUILD_MARINE_DATA=true in .env.production erzwingt den Neuimport stattdessen beim nächsten regulären Deployment. Der systemd-Dienst hat bewusst kein Startzeitlimit, da Download, Indexbau und Import auf kleineren Servern deutlich länger als 90 Sekunden dauern können.

Status und Logs:

systemctl list-timers 'watermaps-*'
journalctl -u watermaps-auto-deploy.service
journalctl -u watermaps-route-update.service
journalctl -u watermaps-certbot-renew.service
docker compose \
  --project-directory /opt/watermaps/current \
  --env-file /opt/watermaps/deploy/.env.production \
  --env-file /opt/watermaps/deploy/.env.images \
  -f /opt/watermaps/current/deploy/compose.production.yml ps