Es gibt diese ganz besondere Art von Engineering-Projekt, die mit einem Satz beginnt wie:
So kompliziert kann das eigentlich nicht sein.
Wir hatten eine Website. Wir hatten Git. Wir hatten einen Deployment-Prozess, der funktionierte.
Ein Webhook erreichte den Webserver, das Repository wurde aktualisiert, Composer erledigte seinen Teil, Yarn baute die Frontend-Assets, ein paar eigene Deployment-Kommandos liefen durch – und kurze Zeit später war die neue Version online.
Völlig vernünftig.
Nur war dieses Deployment-Modell mit der Zeit zu einem der älteren Bestandteile eines Stacks geworden, der sich rundherum deutlich weiterentwickelt hatte.
Wir arbeiteten auf Version 4 von Charm hin, unserem eigenen PHP-Framework. Gleichzeitig hatten wir darauf gerade eine vollständig neue Neoground-Website aufgebaut. Auch die Website selbst war ein klarer Schnitt mit der Vergangenheit: neues Repository, neue Unternehmensidentität, eine wesentlich bewusstere Frontend-Architektur, eigenes UI statt eines weiteren Bootstrap-artigen Fundaments und unter der visuellen Oberfläche eine deutlich sauberere technische Struktur.
Die neue Unternehmenslinie lautet:
Strategic technology, carried through to execution.
Irgendwann ließ sich die Ironie nur noch schwer ignorieren, dass ausgerechnet die Website, die diesen Anspruch kommuniziert, nach einem Git-Webhook noch direkt auf dem Produktionsserver zusammengebaut wurde.
Also beschlossen wir, das Deployment zu modernisieren.
Wir wollten doch nur eine PHP-Website deployen.
Natürlich hatten wir zwei Tage später eine kleine Software-Supply-Chain gebaut.
Das alte System war eigentlich gar nicht schlecht
Das sollte man vorweg sagen.
Es gab keinen dramatischen Produktionsausfall, der uns zu einem Rewrite gezwungen hätte. Keine heldenhafte Migration weg von irgendeinem unwartbaren Shell-Skript, das nur noch von Klebeband und guten Absichten zusammengehalten wurde.
Der bisherige Prozess sah ungefähr so aus:
push
↓
Forgejo webhook
↓
production server
↓
update Git working tree
↓
composer install
↓
yarn install
↓
build CSS and JavaScript
↓
custom deployment hooks
↓
done
Für ein kleines Team und vergleichsweise überschaubare Anwendungen kann das erstaunlich gut funktionieren.
Charm-Anwendungen bringen zudem ihre eigene Deployment-Semantik mit. Es kann Datenbankmigrationen geben, generierte Sitemaps, Caches, Applikationszustände und andere Dinge, die nach einer Codeänderung erledigt werden müssen. Die entsprechenden Kommandos hatten wir längst.
Das eigentliche Problem war also weniger, was beim Deployment geschah, sondern wo die Verantwortlichkeiten lagen.
Production war gleichzeitig dafür zuständig,
- die Anwendung auszuliefern;
- den Source-Checkout zu halten;
- PHP-Abhängigkeiten aufzulösen;
- Frontend-Abhängigkeiten aufzulösen;
- Assets zu kompilieren;
- Deployment-Logik auszuführen.
Damit läuft auf dem Produktionssystem nicht einfach nur Software.
Es ist gleichzeitig einer der Orte, an denen diese Software hergestellt wird.
Und sobald man diesen Unterschied einmal bewusst wahrgenommen hat, lässt er sich nur schwer wieder ignorieren.
Woanders bauen, das Ergebnis deployen
Die konzeptionelle Änderung war einfach:
OLD
source code
↓
production server
↓
build + run
NEW
source code
↓
CI
↓
build artifact
↓
production
↓
run
Der Produktionsserver sollte etwas bekommen, das bereits vollständig fertig ist.
Kein yarn install.
Kein Sass-Compiler.
Kein esbuild.
Keine Composer-Auflösung von Dependencies.
Kein vollständiges Dependency-Ökosystem auf Production, nur weil ich irgendwo eine Überschrift geändert habe.
Die naheliegende Antwort war CI.
Wir verwenden Forgejo, das Actions und Runner ohnehin bereits mitbringt. Es gab also wenig Grund, dafür noch eine weitere CI-Plattform einzuführen.
Einfach.
Bis unmittelbar die interessantere Frage auftauchte:
Wo soll ein Runner eigentlich beliebigen Build-Code ausführen dürfen?
CI ist kontrollierte Ausführung beliebigen Codes
Es ist verführerisch, einen Runner einfach auf einen bereits vorhandenen Server zu setzen.
Dort gibt es ungenutzte CPU-Kapazität. Genug RAM. Docker läuft ohnehin.
Warum also noch eine Maschine verschwenden?
Das Problem ist: Ein CI-Runner ist ein ziemlich ungewöhnlicher Dienst. Sein ganzer Zweck besteht darin, Anweisungen aus Repositories entgegenzunehmen und auszuführen.
Sobald Docker ins Spiel kommt, wird das noch relevanter.
Ein Workflow mit Zugriff auf den Docker-Daemon kann faktisch enorme Kontrolle über den Docker-Host erhalten. Auf einer dedizierten CI-Maschine kann das vollkommen akzeptabel sein. Auf einem System, auf dem daneben noch allerlei andere Dienste laufen, gefällt mir diese Vorstellung erheblich weniger.
Ich wollte nicht, dass eine kompromittierte Dependency, ein fehlerhafter Workflow oder irgendein zukünftiger Konfigurationsfehler aus
build this website
plötzlich
welcome to the infrastructure
macht.
Also brauchte CI eine eigene Vertrauensgrenze.
Diese Anforderung blieb über die gesamte Entwicklung hinweg konstant – auch wenn sich die konkrete Umsetzung mehrfach änderte.
Die technisch elegante Lösung wurde zur falschen Lösung
Eine naheliegende Variante war eine VM auf einem unserer Linux-Hosts.
KVM. Libvirt. Debian-Cloud-Image. Automatisierte Provisionierung. Eine disposable CI-Maschine.
Technisch mochte ich die Idee.
Operativ zunehmend weniger.
Der betreffende Host besitzt bereits eine ziemlich umfangreiche Netzwerkkonfiguration. Docker allein hat dort genügend Bridges, virtuelle Interfaces und veth-Geräte erzeugt, dass ich bereits Software erlebt habe, die sich überraschende Interfaces aussucht oder bei der Interpretation von 0.0.0.0 in zunehmend kreativen Netzwerktopologien die Orientierung verliert.
Noch ein Virtualisierungsnetzwerk, noch eine Bridge, zusätzliches Routing und weitere Firewall-Policies hinzuzufügen, wäre selbstverständlich möglich gewesen.
Es war nur exakt die Art von Komplexität, die ich einem ansonsten stabilen Host nicht hinzufügen wollte, nur damit gelegentlich CSS kompiliert werden kann.
Ein separater günstiger VPS hätte das Problem gelöst.
Ein eigener Mini-PC ebenfalls.
Ich habe sogar noch einen alten Intel NUC, der sich wunderbar als kleines stromsparendes CI-Gerät angeboten hätte. Bei Bedarf aufwecken, Runner Jobs abarbeiten lassen, anschließend wieder herunterfahren.
Mit dieser Idee haben wir uns tatsächlich eine ganze Weile beschäftigt.
Bis irgendwann die beinahe peinlich offensichtliche Lösung auftauchte.
Der CI-Server stand bereits unter meinem Schreibtisch
Aktuell bin ich derjenige, der die Entwicklung macht.
Wenn ich etwas deploye, ist mein Arbeitsplatzrechner daher mit recht hoher Wahrscheinlichkeit eingeschaltet.
VMware Workstation war ohnehin vorhanden.
Also habe ich eine Debian-VM angelegt.
Das war's.
Kein neues Host-Networking auf Production.
Keine zusätzliche VPS-Rechnung.
Kein wiederbelebter NUC.
Keine Wake-on-LAN-Automatisierung.
Und auch kein Grund dafür, dass das CI-System nachts um drei erreichbar sein muss, wenn ohnehin niemand entwickelt.
Während die VM läuft, bekommt sie einen ordentlichen Teil der CPU- und RAM-Ressourcen der Workstation. Damit ist sie sogar erheblich schneller als der kleine NUC es gewesen wäre.
Sie besitzt eine kleine XFCE-Oberfläche, weil es manchmal schlicht praktisch ist, eine echte grafische Konsole direkt in Reichweite zu haben. Der Forgejo-Runner selbst läuft ganz normal als systemd-Service.
Wenn ich deployen möchte:
start VM
↓
runner comes online
↓
queued job executes
Wenn ich fertig bin, schalte ich sie wieder aus.
Und wenn die Umgebung irgendwann unübersichtlich wird, gibt es VMware-Snapshots.
Manchmal besteht die anspruchsvolle Architekturentscheidung schlicht darin, herauszufinden, welche Komplexität man gerade nicht braucht.
Sollte die Last später wachsen, kann dieselbe Runner-Rolle immer noch auf einen kleinen VPS oder ein dediziertes Gerät wandern.
Den Workflows ist das weitgehend egal.
Wir bauen unsere eigene Build-Umgebung
Die VM selbst ist trotzdem noch nicht die eigentliche Build-Umgebung für die Anwendung.
Das übernimmt Docker.
Dadurch bekommt jeder CI-Job einen frischen Container, und wir können exakt definieren, welche Umgebung eine Charm-Anwendung erwartet.
Also haben wir ein eigenes CI-Image gebaut.
Die derzeitige Grundlage sieht ungefähr so aus:
Debian 13
PHP 8.5
Composer 2
Node.js 24 LTS
Yarn 4
Corepack
Valkey-compatible PHP tooling
Git
SSH
rsync
tar
zstd
jq
required PHP extensions
Ein paar Details daran gefallen mir besonders.
Node kommt aus dem offiziellen Node-Image.
Composer kommt aus dem offiziellen Composer-Image.
PHP kommt aus dem offiziellen PHP-8.5-Trixie-Image.
Über Multi-Stage-Builds können wir diese Images als Quellen verwenden und daraus unsere finale Umgebung zusammensetzen.
Im Ergebnis entsteht damit praktisch eine kleine standardisierte Neoground-Build-Workstation:
Forgejo job
↓
Docker
↓
our PHP build image
↓
project source
Projektspezifische Kommandos bleiben im Workflow des jeweiligen Projekts.
Stabiles Tooling gehört in das Image.
Diese Trennung stellte sich als wichtiger heraus, als sie zunächst klingt.
CI fand sofort etwas, das ich vergessen hatte
Dann scheiterte der erste echte Application-Build.
Gut.
Genau dafür ist CI schließlich auch da.
Das schuldige Kommando war:
yarn install --immutable
Yarn meldete, dass sich dafür das Lockfile ändern müsste.
Was verboten war.
Nach etwas Suche stellte sich der Grund als herrlich banal heraus:
Das Projekt lief immer noch auf Yarn 1.
Irgendwie hatte diese uralte lokale Annahme still und unauffällig überlebt, während Node, PHP, die Anwendung und praktisch der gesamte Rest des Stacks längst weitergezogen waren.
Meine Entwicklungsmaschine wusste noch, wie damit umzugehen war, weil sie über Jahre hinweg genau den historischen Zustand angesammelt hatte, der dafür nötig war.
Eine saubere CI-Umgebung wusste das nicht.
Und genau solche Dinge sollen reproduzierbare Builds sichtbar machen.
Wir migrierten sauber auf Yarn 4, ergänzten die Projektkonfiguration, aktualisierten das Lockfile und pinnten die Package-Manager-Version.
Danach funktionierte:
yarn install --immutable
Keine implizite Migration.
Kein heimlich verändertes Lockfile.
Kein beim Production-Build spontan erfundener Dependency-Zustand.
Das Repository beschreibt nun tatsächlich, was es benötigt.
Dieser eine kleine Fehler hatte die gesamte Übung bereits gerechtfertigt.
Charm wird Teil der Pipeline
Das Projekt für unser erstes echtes Deployment war die neue Neoground-Website.
Und sie ist zugleich ein zunehmend guter Test dafür, wohin sich Charm v4 entwickelt.
Charm ist unser PHP-Framework und gleichzeitig die Application Foundation unserer eigenen Systeme. Es ist über Jahre aus einer recht einfachen Philosophie heraus gewachsen: Anwendungen sollen die Infrastruktur bekommen, die sie wirklich brauchen, ohne dass das Programmiermodell unnötig aufgebläht oder undurchsichtig wird.
Charm ist modular aufgebaut, nutzt modernes PHP und integriert unter anderem Twig, Datenbankzugriff, Queues, Events, Caching und Konfiguration. Vor allem aber betreibt es unsere tatsächlichen Anwendungen und existiert nicht primär als Framework-Experiment.
Die Deployment-Pipeline war daher ein guter weiterer Test:
Kann eine Charm-Anwendung sauber durch einen modernen Build- und Release-Prozess laufen, ohne dass sich die Anwendung dabei dem CI-System unterordnen muss?
Die Antwort lautet: ja.
Der Production-Build macht inzwischen im Wesentlichen Folgendes:
checkout repository
↓
set production environment
↓
composer install --no-dev
↓
yarn install --immutable
↓
build Sass + JavaScript
↓
remove node_modules
↓
verify expected output
↓
package release
Der Frontend-Build verwendet unsere bestehende Sass- und esbuild-Pipeline.
Composer installiert exakt den für Production vorgesehenen Dependency-Satz.
Das daraus resultierende Verzeichnis ist nicht länger Sourcecode, der noch darauf wartet, zur Anwendung zu werden.
Es ist die Anwendung.
Eine Website wird zum Artefakt
Ab diesem Punkt wurde das Ganze auf eine merkwürdig befriedigende Weise greifbar.
Statt ein Git-Repository durch die Infrastruktur zu schieben, verwandelt CI den vollständigen Production-Tree in ein mit Zstandard komprimiertes Tar-Archiv.
Ein typischer Build landet momentan ungefähr bei:
source repository: ~20 MB
production artifact: ~13 MB
Zusätzlich erzeugen wir einen SHA-256-Hash sowie strukturierte JSON-Metadaten.
Konzeptionell:
website-<build>.tar.zst
website-<build>.tar.zst.sha256
website-<build>.json
Die Metadaten enthalten unter anderem:
{
"project": "...",
"source": {
"commit": "...",
"ref": "..."
},
"artifact": {
"filename": "...",
"size_bytes": 0,
"sha256": "..."
},
"build": {
"environment": "Prod",
"php": "8.5.x",
"node": "v24.x",
"yarn": "4.x"
}
}
Das genaue Format wird sich sicherlich noch weiterentwickeln. Das Prinzip dahinter ist jedoch ausgesprochen nützlich.
Ein Deployment-Artefakt sollte selbst beantworten können:
- Zu welchem Projekt gehöre ich?
- Aus welchem Source-Commit bin ich entstanden?
- Welcher Build hat mich erzeugt?
- Welche Umgebung wurde dafür verwendet?
- Wie groß bin ich?
- Welchen Checksum-Wert sollte ich besitzen?
Git bleibt die Source of Truth für den Code.
Das Artefakt wird zur Source of Truth dafür, was wir tatsächlich deployen.
Dann wurde „Datei hochladen“ plötzlich zum Sicherheitsproblem
Zu diesem Zeitpunkt hatten wir ein hübsches kleines Archiv in CI liegen.
Jetzt musste es nur noch aus CI heraus.
Die einfachste Variante wäre gewesen, der CI-Maschine normalen SSH-Zugriff auf irgendein Zielsystem zu geben.
Ganz sicher nicht.
Der Sinn der gesamten CI-Isolation bestand schließlich darin, ihre Befugnisse möglichst eng zu halten.
Also wurde selbst die Veröffentlichung des Artefakts zu einer Art Capability-Modell.
Die CI-Umgebung besitzt eigene SSH-Credentials, die nicht in eine normale Shell-Session übergehen können. Serverseitig sind sie auf eine sehr eng definierte rsync-Operation und eine Artifact-Inbox beschränkt.
Mit diesen Credentials kann man nicht einfach auf dem Server herumlaufen.
Man kann daraus keinen Administrator machen.
Bestehende kanonische Releases lassen sich damit ebenfalls nicht überschreiben.
Ihr Zweck lässt sich im Wesentlichen so zusammenfassen:
Du darfst hier neue Kandidaten für Releases ablegen.
Mehr braucht CI nicht.
Übertragen werden:
archive
checksum
metadata
wobei die Metadaten zuletzt eintreffen.
Dieses kleine Detail gibt uns gleichzeitig ein angenehm simples Completion-Protokoll.
Bricht ein Upload mitten beim Übertragen des Archivs ab, existiert anschließend noch kein vollständiges Metadatenobjekt.
Die Ingestion-Seite muss daher nicht raten, ob eine Datei schon vollständig übertragen wurde.
Keine Metadaten bedeutet:
noch nicht bereit.
Langweilige Primitive können erstaunlich wirkungsvoll sein.
CI endet ganz bewusst an der Inbox
Diese Grenze wollte ich ebenfalls beibehalten.
CI deployt die Website nicht.
CI erzeugt und veröffentlicht ein Artefakt.
Deployment gehört zur Infrastruktur.
Und an dieser Stelle kommt ein weiterer Teil unseres Stacks ins Spiel: Compass.
Über Compass habe ich vor Kurzem bereits im Neoground-Blog geschrieben:
Compass: Unser schlankes Hosting-Control-Plane
Compass sitzt oberhalb unserer Provisionierungs- und Infrastrukturwerkzeuge und gibt uns eine strukturierte Schicht, über die wir die von uns betriebenen Systeme verwalten können.
Artifact-Deployments passen sehr natürlich in dieses Modell.
Die öffentlich beschreibbare Architektur sieht ungefähr so aus:
Git / Forgejo
↓
CI runner
↓
custom build container
↓
verified artifact
↓
restricted inbox
↓
Compass + provisioning layer
↓
artifact catalog
↓
controlled deployment
Sobald ein Artefakt eintrifft, kann die Infrastrukturseite es validieren, die Metadaten einlesen, es registrieren und als deploybares Release verfügbar machen.
Eine konkrete Version lässt sich anschließend über unsere Werkzeuge auswählen.
CI benötigt keinerlei Befugnis, diesen Schritt selbst auszuführen.
Diese Trennung ist ziemlich wichtig.
BUILD SYSTEM
produces releases
DEPLOYMENT SYSTEM
decides what goes live
Beide Aufgaben hängen eng zusammen.
Es ist trotzdem nicht dieselbe Verantwortung.
Echte Deployments enthalten Zustand
Es gab noch einen weiteren Grund, weshalb das bloße Entpacken des Archivs über die bestehende Website die falsche Lösung gewesen wäre.
Reale Anwendungen sind nicht immer vollständig immutable Verzeichnisbäume.
Unsere Charm-Anwendungen enthalten bewusst einige persistente Zustände innerhalb ihrer Anwendungsstruktur.
Zum Beispiel:
var/logs
var/cache
data
Logs sollen ein Deployment natürlich überleben.
Standortspezifische beziehungsweise installationsspezifische Daten ebenfalls.
Das Cache-Verzeichnis bleibt strukturell bestehen, auch wenn sein Inhalt bei einem Deployment bewusst geleert werden kann.
Unser Deployment-Handler betrachtet das entpackte Artefakt deshalb als sauberen Master State, gleicht diesen aber mit der bestehenden Anwendung ab, statt einfach stumpf alles zu ersetzen.
Konzeptionell:
artifact
↓
verify checksum
↓
extract into staging
↓
validate release
↓
reconcile application tree
│
├── preserve persistent data
├── preserve logs
└── clear disposable cache
↓
Charm post-deploy command
Anschließend bekommt Charm über einen applikationsseitigen Deployment-Hook das letzte Wort.
Ein Projekt muss vielleicht:
- Migrationen ausführen;
- eine Sitemap neu generieren;
- weitere Caches leeren;
- generierte Strukturen aktualisieren;
- andere anwendungsspezifische Wartungsschritte durchführen.
Die Infrastrukturschicht sollte all diese Semantik nicht im Detail kennen müssen.
Sie bringt den neuen Applikationszustand sauber an seinen Platz.
Die Anwendung selbst weiß, was sie anschließend tun muss, um vollständig wieder sie selbst zu werden.
Kein /public-Verzeichnis? Funktioniert trotzdem.
Charm-Anwendungen folgen außerdem nicht einer inzwischen sehr verbreiteten PHP-Konvention: die gesamte extern erreichbare Oberfläche unterhalb eines dedizierten /public-Verzeichnisses abzulegen.
Das ist bewusst so.
Unsere Applikationsstruktur stammt aus einer Zeit, bevor dieses Muster beinahe universell wurde, und direkt verfügbare statische Assets haben für die Organisation von Charm-Projekten einige praktische Vorteile.
Stattdessen definiert die Webserver-Konfiguration die Sicherheitsgrenze explizit und sehr genau und legt fest, welche Ressourcen tatsächlich ausgeliefert werden dürfen.
Diese Architektur funktioniert für uns ausgesprochen gut.
Und die gesamte Übung hat noch einmal etwas bestätigt, das mir beim Infrastrukturdesign wichtig ist:
Das Deployment-System sollte die Architektur der Anwendung verstehen, statt jede Anwendung in das bevorzugte Verzeichnislayout des Deployment-Systems zu zwingen.
Generische Rezepte sind gute Ausgangspunkte.
Naturgesetze sind sie nicht.
Und dann funktionierte es
Irgendwann gab es nichts mehr zu theoretisieren.
Projekt pushen.
Forgejo plant den Workflow ein.
Der Runner übernimmt ihn.
Docker erzeugt die Build-Umgebung.
PHP 8.5 startet.
Composer installiert die Production-Dependencies.
Yarn führt eine immutable Dependency-Installation aus.
Sass und esbuild bauen das Frontend.
Der Build-Tree wird bereinigt.
Das Artefakt wird komprimiert.
SHA-256 wird berechnet.
Die Metadaten werden erzeugt.
Die drei Release-Objekte landen in der eingeschränkten Inbox.
Die Infrastrukturschicht übernimmt das Artefakt.
Die Website wird deployt.
Und der gesamte Build- und Publication-Prozess dauert ungefähr 30–40 Sekunden.
Weniger als eine Minute nach dem Start mit dem Repository-Source haben wir ein vollständiges, verifiziertes und deploybares Website-Artefakt.
Das Ergebnis ist beinahe enttäuschend unspektakulär.
Und genau so möchte ich Deployment-Infrastruktur haben.
Was haben wir damit eigentlich verbessert?
Man könnte das Ganze sehr einfach zusammenfassen:
Wir haben
git pulldurch CI/CD ersetzt.
Damit würde man allerdings den interessantesten Teil verpassen.
Was sich tatsächlich verändert hat, ist die Verteilung der Verantwortlichkeiten.
Production ist keine Build-Maschine mehr
Das Produktionssystem bekommt vorbereitete Software, statt sie selbst zusammenzubauen.
Build-Zustand ist explizit geworden
Versionen von PHP, Composer, Node und Yarn werden durch Build-Umgebung und Projektkonfiguration bestimmt – nicht durch das, was zufällig auf irgendeinem Server installiert ist.
CI besitzt eine eigene Vertrauensgrenze
Repository-gesteuerter Code läuft nicht mehr direkt neben unabhängiger Infrastruktur.
Releases sind identifizierbare Objekte geworden
Ein Artefakt ist mit Source-Commit, Checksum, Metadaten und Build-Umgebung verknüpft.
CI besitzt keine Deployment-Autorität mehr
CI darf einen Release-Kandidaten veröffentlichen.
Die Infrastruktur entscheidet, was tatsächlich live geht.
Anwendungssemantik bleibt bei der Anwendung
Charm übernimmt über seinen Post-Deployment-Lifecycle genau jene Arbeiten, die nur die Anwendung selbst wirklich verstehen kann.
Für nichts davon brauchten wir Kubernetes.
Irgendwo versteckt sich auch keine riesige Orchestrierungsplattform.
Die entscheidenden Bestandteile sind weiterhin erstaunlich gewöhnlich:
Git
Forgejo
Linux
Docker
PHP
Node
Yarn
tar
Zstandard
SHA-256
SSH
rsync
Die eigentliche Raffinesse liegt vor allem darin, zu entscheiden, wo jedes dieser Werkzeuge hingehört und was es dort tun darf.
Es war trotzdem nur eine Website
Darin steckt etwas herrlich Typisches für Software Engineering.
Die sichtbare Aufgabe lautete:
Bring die neueste Version einer PHP-Website auf einen Server.
Unterhalb dieses einen Satzes landeten plötzlich Source Control, CI-Orchestrierung, Virtualisierung, Container, Package Management, PHP-Runtime-Design, Frontend-Kompilierung, Artifact Engineering, SSH-Sicherheit, Filesystem-Semantik und Application Lifecycle Management.
Aus fünf Schichten werden erstaunlich schnell acht, wenn man ein Problem nur lange genug betrachtet.
Und trotzdem ist das Ergebnis am Ende tatsächlich einfacher zu betreiben.
Genau das ist der entscheidende Punkt.
Die erste echte Anwendung, die diese gesamte Reise vollständig durchlaufen hat, war unsere komplett neu aufgebaute Neoground-Website.
Eine neue Unternehmensidentität.
Ein neues Repository.
Ein neues Frontend.
Darunter Charm auf dem Weg zu Version 4.
Drumherum eine neue Release-Pipeline.
Und auf der Website selbst:
Strategic technology, carried through to execution.
Offenbar haben wir das etwas wörtlich genommen.
Noch keine Kommentare
Kommentar hinzufügen