Enterprise-Drupal-Deployment auf Mittwald via Azure DevOps mit Capistrano-Symlink-Releases
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
- Bootstrap-Pflicht-Schritte vor erstem Deploy (Plan-File
docs/plans/2026-05-04-001-deploy-mittwald-azure-bootstrap.md): Mittwald-MySQL-DB anlegen, Mittwald-Redis-DB anlegen, beide mit derstaging-App verlinken, Domain auf App zeigen lassen, Document Root der App von/auf/current/webändern,shared/-Verzeichnis-Struktur anlegen,settings.mittwald.phpinsshared/legen, DDEV-DB-Dump in Mittwald-MySQL restoren, beide Cronjobs registrieren. - Repo-Vorbereitung vor erstem Pipeline-Run:
composer require drupal/redis, neueweb/sites/default/settings.mittwald.php.exampleals Template,azure-pipelines.ymlschreiben, Azure-Variable-Groupsolcom-mittwald-secrets-test(später-production) anlegen mitMITTWALD_API_TOKEN+MITTWALD_PROJECT_ID+MITTWALD_APP_ID_TEST, GPG/SSH-Keys der CI-Runner als SSH-User auf der Mittwald-App registrieren. - Maintenance-Window ist ehrlich beworben, nicht weg-rationalisiert. Ein Deploy mit
drush updatedb+drush config:importist ~10–60 s offline (Banner). Stakeholder-Kommunikation: Deploys nicht zur Recruiter-Hauptzeit (10:00–17:00 CET) ausführen. - DB-Rollback ist nicht trivial. Wer ein Schema-breaking Update aus Versehen deployed hat, kann via Symlink die Code-Version zurückbiegen, bekommt aber Fatal Errors auf den entfernten Feldern. Forward-Fix ist der dokumentierte Weg. Disziplin in Migrate-Reviews wird damit Pflicht.
- Engine-Mismatch DDEV-MariaDB ↔ Mittwald-MySQL wird bewusst getragen. Beim B2-Bootstrap kann ein einmaliges Konvertierungs-
sedauf Collation-Strings nötig werden. - Ohne Drupal-Cron läuft auf der Test-App nichts Periodisches (Vacancy-Migration, Queue-Worker, Cache-Cleanup). Mittwald-Cronjob-Setup ist deshalb Bootstrap-Pflicht, nicht „nice-to-have".
- Lock-in auf Mittwald-Komponenten: Redis als Mittwald-DB-Komponente, MySQL als Mittwald-DB-Komponente. Migration auf einen anderen Hoster wäre kein triviales Lift-and-Shift. Akzeptiert, weil Mittwald als Provider gesetzt ist.
- Vector-DB-Architektur ist offen. Falls AI-Vektor-Suche real wird, kommt sie als separater externer Service. ADR-Review-Trigger: erste konkrete Embedding-/Vector-Anforderung vom Produkt-Team.
- Prod-Bootstrap später ist ein eigener ADR. Dieser ADR adressiert nur den Test-Pfad und die Pipeline-Architektur, die Test+Prod gemeinsam tragen.
- Kein
customer-lens-reviewer-Konsult vorgeschaltet, weil dieser Agent für Komponenten-Topologie / UI-Designentscheidungen scopiert ist, nicht für Hosting-/Deploy-Architektur.