Architecture Decision Record · 0019

Enterprise-Drupal-Deployment auf Mittwald via Azure DevOps mit Capistrano-Symlink-Releases

TL;DR — Capistrano-Style Symlink-Release-Topologie auf Mittwald, gebaut von Azure DevOps Pipelines. Deployment-Sequenz mit 10–60 s Maintenance-Window während drush updatedb + drush config:import. Rollback via Symlink-Umbiegen, DB-Rollback via Forward-Fix.

Kontext

SOLCOM wechselt von „solcom.ddev.site lokal genügt" auf eine produktiv erreichbare Drupal-CMS-Site. Drei Faktoren bestimmen die Hosting- und Deploy-Architektur:

Plattform-Constraints (Mittwald + Azure DevOps). Mittwald ist als Hosting-Provider gesetzt (separate Mittwald-mw-CLI-Operative-Choreographie siehe Skill mittwald-hosting). Mittwald liefert Apps, managed MySQL und managed Redis als Datenbank-Komponenten, Domains mit automatischem Let's-Encrypt, Cronjobs, SSH/SFTP, Backups. Container sind verfügbar, sind aber Self-Managed (kein Mittwald-Backup, kein Mittwald-Monitoring). Postgres ist nicht als managed Komponente verfügbar.

CI/CD läuft über Azure DevOps Pipelines (vorhandenes Konzern-Setup, vom Auftraggeber gesetzt). Vorlagen liegen in .claude/skills/drupal-ci-cd/SKILL.md und nutzen mw app upload (rsync) plus mw app exec (Drush) als Deploy-Mechanismus. Kein mw deploy und kein mw login — beides Mythen aus älteren Marketplace-Briefings.

Drupal-Stack-Eigenschaften. Drupal 11.3 + Drupal CMS 2.1.1 Distribution + Drupal Canvas + custom SDC-Family + solcom_vacancy-Migration gegen die SOLCOM-API + config_split.dev für lokale Dev-Module. Update-Pfade brechen DB-Schema und Config-Cache regelmäßig — Drupal ist keine Rolling-Deploy-Architektur; jede Schema-Änderung ist ohne explizite Expand-Migrate-Contract-Disziplin nicht rückwärts-kompatibel.

Aufstellung „Enterprise". Der Auftraggeber hat „Zero-Downtime-Deployments mit Symlinks, wirklich Enterprise" formuliert. Im Grilling kristallisiert sich „Enterprise" heraus als: atomarer Code-Switch via Symlink, sauberer Rollback, getrennte Stages mit klaren Triggern, reproducible Build-Artefakte, kein hand-konfiguriertes Live-Editing auf der Mittwald-Maschine. Echtes Hard-Zero-Downtime ist für die jetzige Team-Größe und Drupal-Migrate-/Config-Diziplin unrealistisch (siehe Rejected alternatives).

Aktueller Mittwald-Stand (Inventur 2026-05-04). Project Main (UUID 6709341f-…) hat 1 App staging (UUID 61284ca9-…, PHP 8.4.18 / MySQL 8.0.40 / Composer 2.7.7, Document Root /). Keine DB verknüpft, keine DBs angelegt, kein Cronjob, 1 Domain p-nxm58l.project.space ohne Ziel. Greenfield-Bootstrap-Pflicht.

Entscheidung

Wir deployen mit einer Capistrano-Style Symlink-Release-Topologie auf Mittwald, gebaut von Azure DevOps Pipelines, mit einem Maintenance-Banner-Window von 10–60 Sekunden während der Drupal-Update-Phase und Rollback ausschließlich via Symlink-Umbiegen plus Forward-Fix bei DB-Inkompatibilität.

Die Entscheidung ist eine gebündelte Architektur-Wahl über zwölf Sub-Punkte. Jeder Sub-Punkt hat im Grilling eine konkrete Alternative gegenübergestellt bekommen. Reihenfolge folgt der Abhängigkeits-Kette.

S1 — Scope: Pipeline für Dev → Test → Prod, heute nur Test aktiv Stages

Der Pipeline-Aufbau trägt von Anfang an drei Stages: lokales DDEV-Dev (Dev), Mittwald-App staging (Test), spätere zweite Mittwald-App (Prod). Heute wird nur Test betrieben. Die Mittwald-App heißt staging aus Mittwald-Sprachgebrauch, im Projekt-Glossar entspricht sie Test.

S2 — Build: Artefakt-on-CI, identisches Tarball für alle Stages Build

Die Pipeline baut einmal pro Pipeline-Run ein Deploy-Artefakt:

composer install --no-dev --prefer-dist --optimize-autoloader
npm ci && npm run build
→ Tarball (ohne .git, .ddev, node_modules, tests, settings.local.php, web/sites/*/files)

Das Artefakt enthält vendor/, web/core/, web/modules/contrib/, web/themes/contrib/, web/profiles/contrib/, web/libraries/, custom code, theme-Build-Output (web/themes/custom/byte_theme/build/main.min.css).

Reproduzibilität: composer.lock und package-lock.json müssen committed sein. Mittwald führt kein composer install und kein npm install aus.

S3 — Persistente Pfade: shared/ mit drei Einträgen Filesystem

Auf Mittwald liegt parallel zum releases/-Verzeichnis ein shared/-Verzeichnis. Drei Einträge werden in jeden Release rein-symlinkt:

Pfad in releases/<sha>/ Symlink-Ziel
web/sites/default/files/ /<app-root>/shared/files/
web/sites/default/private/ /<app-root>/shared/private/
web/sites/default/settings.mittwald.php /<app-root>/shared/settings.mittwald.php

tmp/ ist nicht im shared/, Drupal regeneriert tmp pro Request. Drupal-Logs sind DB-basiert (Watchdog), keine File-Logs.

settings.mittwald.php wird einmalig im Bootstrap manuell ins shared/ gelegt und enthält DB-/Redis-Credentials, Hash-Salt, Trusted-Host-Pattern. Sie wird nie via rsync angefasst.

S4 — Downtime-Modell B: Minimal-Window mit Maintenance-Banner Deploy

Drupal-Site geht während drush updatedb + drush config:import + drush cache:rebuild für 10–60 Sekunden auf system.maintenance_mode = 1. User sehen ein Maintenance-Banner. Der atomare Code-Switch (Symlink-Umbiegen) ist ~1 ms — die Window-Dauer ist die DB-/Config-Phase.

Sequenz pro Deploy (vereinfachte Form, vollständig im Plan dokumentiert):

1. mw app upload --remote-sub-directory=releases/<sha> ./build-artifact
2. ln -sfn shared/files     releases/<sha>/web/sites/default/files
   ln -sfn shared/private   releases/<sha>/web/sites/default/private
   ln -sfn shared/settings.mittwald.php  releases/<sha>/web/sites/default/settings.mittwald.php
3. drush state:set system.maintenance_mode 1 -y       (auf altem current)
4. ln -sfn releases/<sha>   current                   (atomarer Switch)
5. drush updatedb -y                                  (auf neuem current)
6. drush config:import -y
7. drush cache:rebuild
8. drush state:set system.maintenance_mode 0 -y
9. echo <sha> > shared/last-release.txt
10. cleanup: keep latest 5 releases, rm rest
S5 — Rollback R2: Symlink-Rollback + Fix-forward bei DB Rollback

Code-Rollback: Symlink-Umbiegen auf vorherigen Release in 1 ms. Releases-Retention 5 (siehe S11) gibt Rollback-Tiefe.

DB-Rollback: kein automatischer DB-Restore. Bei DB-Inkompatibilität nach Code-Rollback wird nicht zurück-, sondern vorwärts gefixt — neuer Branch, neue Migration, neuer Deploy. Mittwald-tägliches DB-Backup ist Last-Resort, nicht First-Line.

S6 — Redis: jetzt mit-bootstrappen, Cache + Lock + Queue Cache

Mittwald-Redis-Komponente wird im Bootstrap angelegt und mit der App verlinkt. composer require drupal/redis ist Teil des Initial-Setups. settings.mittwald.php konfiguriert Redis als Cache-Backend, Lock-Backend und Queue-Backend. Drupal default-DB-Cache wird bewusst umgangen.

S7 — Trigger T1: main → Test (auto), Tag v* → Prod (manual approval) CI/CD

Pipeline-Stages und Trigger:

Stage Trigger Approval
PR-Validation (Quality + Tests, kein Deploy) PR gegen main
Build (Artefakt) Push auf main, Tag v*
DeployTest Push auf main, nach Build
DeployProd Tag v*, nach Build manual approval gate

Trunk-based: main ist deployable. Tag v1.2.3 markiert Prod-Releases. Rollback auf Prod = re-trigger DeployProd mit altem Tag.

S8 — Bootstrap-Inhalt Test (B2): DDEV-DB-Dump nach Mittwald Bootstrap

Allererstes Test-Bootstrap (einmaliger manueller Schritt, nicht in der Pipeline):

ddev export-db --gzip-file=/tmp/solcom-test-bootstrap.sql.gz
gunzip /tmp/solcom-test-bootstrap.sql.gz
# Restore in Mittwald-MySQL via mw db oder direkt mysql-CLI

Beim Restore wird der MariaDB-↔-MySQL-Engine-Mismatch (siehe S12) als Lackmustest geprüft.

Prod-Bootstrap später ist explizit nicht B2 — Prod startet als Greenfield + Migration-Run gegen die Live-API (Variante B3 für Prod, separat zu beschließen).

S9 — Drupal-Cron C1: SSH + drush cron alle 15 Min, separater stündlicher Migrate-Cron Cron

Mittwald-Cronjob-Komponente bekommt zwei Einträge:

*/15 * * * *  /usr/bin/php /<app>/current/vendor/bin/drush --root=/<app>/current/web cron
0    * * * *  /usr/bin/php /<app>/current/vendor/bin/drush --root=/<app>/current/web migrate:import solcom_vacancy --update

SSH-User der App führt aus, voller Drupal-Bootstrap, kein PHP-FPM-Lifetime-Limit. Vacancy-Migration läuft stündlich gegen die SOLCOM-API.

S10 — Domain: Mittwald project.space für Test, www.solcom.de für Prod vorbereitet Domain

Test-URL ist die Mittwald-default-Domain (p-nxm58l.project.space o. ä.) — kein DNS-Aufwand. SSL via Mittwald-Let's-Encrypt automatisch. Document Root der App muss von / auf /current/web geändert werden.

settings.mittwald.php definiert Trusted-Host-Pattern, das beide Stages bereits abdeckt:

// settings.mittwald.php — Trusted-Host-Pattern
$settings['trusted_host_patterns'] = [
  '^(.*\.)?p-nxm58l\.project\.space$',  // Test (konkrete Project-UUID-Subdomain)
  '^www\.solcom\.de$',                    // Prod (vorbereitet)
  '^solcom\.de$',                         // Prod-Apex (vorbereitet)
];
S11 — Releases-Retention: 5 Cleanup

Cleanup-Schritt am Ende jeder Deploy-Sequenz: ls -1tr releases/ | head -n -5 | xargs -r rm -rf. Disk-Footprint ~5 × Tarball-Größe. Rollback-Tiefe auf die letzten fünf Deploys.

S12 — DB-Engine: MySQL für Drupal, externes pgvector später bei Bedarf Database

Drupal-DB ist Mittwald-managed MySQL 8.0.40. Lokale DDEV-MariaDB-11.8-↔-Mittwald-MySQL-8.0.40-Inkompatibilitäten werden akzeptiert und beim B2-Bootstrap als Lackmustest geprüft. Der bekannte Risikoraum (Collation-Defaults, JSON-Funktionen, CHECK-Constraints) ist eng und wird via einmaligem sed auf das Dump-File adressiert, falls beim Restore Friktion auftritt.

Postgres + pgvector wird bewusst nicht jetzt integriert. Falls in Zukunft AI-Vektor-Suche real wird (Vacancy-Embedding-Search etc.), wird das als separater externer Service (Neon, Hetzner Cloud DB, Supabase) angebunden — Drupal-DB-Migration nicht erforderlich, weil Vector-Workloads architektonisch entkoppelt von Drupal-Anwendungs-DB sind.

Rejected alternatives

Composer-on-Server statt Artefakt-on-CI (S2) rejected

Jeder Deploy würde composer install + npm run build auf der Mittwald-App ausführen. Verworfen, weil: Packagist-Outage = Deploy-Fail; Build-Toolchain auf Mittwald = Wartungslast; identische Build-Reproducibility zwischen Test und Prod nicht garantiert.

Hybrid-Build (PHP auf CI, Frontend auf Server) (S2) rejected

Edge-Cases auf zwei Systemen. Erfahrungsgemäß die schlimmste Welt — gleiche Komplexität wie Composer-on-Server bei nur halber Reproducibility. Kein Vorteil gegenüber Artefakt-on-CI.

Variante A — Hard Zero (echtes Zero-Downtime, S4) rejected

User merkt 0 ms. Setzt voraus, dass jede Schema-Änderung als Expand-Migrate-Contract über zwei separate Deploys läuft, jede Config-Änderung rückwärts-kompatibel zum vorherigen Code ist, und alle Migrationen auditiert werden. Verworfen, weil das jetzige Team und die jetzige Migrate-Disziplin nicht darauf eingespielt sind. Kostspielig und fragil.

Variante C — Soft-Switch ohne Maintenance-Mode (S4) rejected

Symlink wird gewechselt, updb + cim laufen auf Live-Traffic. Verworfen, weil aktuelle Migrationen (Card-Family, Vacancy-Field-Schema) regelmäßig schema-breaking sind — das Risiko-Pareto ist eindeutig auf Maintenance-Banner-Seite.

R1 — DB-Snapshot pro Deploy (S5) rejected

Jeder Deploy erstellt einen DB-Dump, Rollback macht Symlink + Restore. Verworfen, weil: Rollback würde Datenverlust für jeden User produzieren, der zwischen Deploy und Rollback gehandelt hat (Bewerbungseingänge, Content-Edits) — bei einer Job-Plattform inakzeptabel.

R3 — Kein dokumentierter DB-Rollback (S5) rejected

Zu lasch, Restrisiko ohne Plan B.

Redis später (S6) rejected

MVP ohne Redis und nachziehen. Verworfen, weil Bootstrap einmal sauber besser ist als Retrofit-Deploy; weil Drupal-Canvas viele Render-Cache-Hits produziert; weil solcom_vacancy-Queue von Redis-Atomizität profitiert.

Kein Redis dauerhaft (S6) rejected

DB-Cache als einziger Backend. Verworfen, weil Render-Performance + Lock-Contention bei Bulk-Operations konkret Probleme produzieren würden.

T2 — Beide Deploys Approval-gated (S7) rejected

Jeder main-Push triggert Test+Prod, beide nach Approval. Verworfen, weil das Trunk-based-Pattern verwässert (Test soll automatisch fließen) und die Tag-basierte Release-Markierung wegfällt.

T3 — Feature-Branches → Test (S7) rejected

Test als PR-Preview. Verworfen, weil eine einzelne Mittwald-App keine parallelen Branches hosten kann ohne ephemere App-Provisionierung — operative Komplexität nicht im Verhältnis zum Nutzen.

T4 — Manual-only-Trigger (S7) rejected

Kein git-Trigger, alles in Azure-DevOps-UI gestartet. Verworfen, weil das den CI-Wert (sofortiges Feedback, automatische Reproducibility) wegwirft.

B1 — Greenfield-Bootstrap für Test (S8) rejected

Test startet leer, Vacancies via Migration. Verworfen für Test, weil die DDEV-Daten kuratiert sind (UI-Komponenten-Arbeit gegen reale Vacancies) und QA mit ihnen arbeitet. Für Prod wird B1+B3 gewählt (separat zu beschließen).

C2 — HTTP-GET /cron/<token> (S9) rejected

Stateless, einfach. Verworfen, weil PHP-FPM-Lifetime-Limit (~30–60 s) für Vacancy-Migration und Queue-Worker zu eng ist. Token in URL ist außerdem ein Sicherheits-Mismatch zu „Enterprise".

C3 — Hybrid HTTP+SSH (S9) rejected

Leichte Tasks per HTTP, Migrate per SSH. Verworfen, weil zwei Cronjobs für minimal mehr Sauberkeit, aber doppelter operativer Verwaltungsaufwand.

D2 — test.solcom.de sofort als Test-Domain (S10) rejected

Eigener DNS-Eintrag heute. Verschoben — project.space-URL für Test reicht für Stakeholder-/QA-Reviews; Custom-Domain für Test ist nachholbar ohne Architektur-Impact.

E2 — Postgres als Mittwald-Container (S12) rejected

pgvector im selben Cluster wie Drupal. Verworfen, weil Mittwald-Backup, Mittwald-Monitoring und Mittwald-DB-Tools für Container-DBs nicht greifen. Operative Komplexität (Postgres-Updates, PITR, Volume-Management) wäre selbst zu tragen.

E3 — Drupal komplett auf externes Postgres-Hosting (S12) rejected

Neon / Supabase / Hetzner. Verworfen, weil zweiter Provider, Latenz-Hop Mittwald-App ↔ externes Postgres, höhere Bootstrap-Komplexität — bei null funktionalem Gewinn (Drupal-Workload ist nicht Postgres-spezifisch). Vector-Workload ist entkoppelt.

Consequences