
Contao Headless Basis-Setup: Installation & Bildkonfiguration

Die Installation von Contao 5 im containerisierten Umfeld
Das Fundament für ein stabiles Contao Headless Basis-Setup beginnt mit einer sauberen CLI-gestützten Installation direkt innerhalb unserer zuvor aufgesetzten Docker-Umgebung. Im Headless-Kontext verzichten wir bewusst auf den grafischen Contao Manager (die contao-manager.phar.php), da eine automatisierbare und reproduzierbare Infrastruktur im Enterprise-Bereich zwingend über die Konsole orchestriert werden muss.
Bei der Installation von Contao 5 müssen wir beachten, dass moderne Webserver-Standards vorausgesetzt werden. Der wichtigste Unterschied zu älteren Generationen des CMS ist der vordefinierte Document Root: Sämtliche öffentlich zugänglichen Dateien müssen im Verzeichnis /public liegen, während sensible Konfigurationen, Caches und Bibliotheken außerhalb des Web-Zugriffs verbleiben.
Die Verbindung zur Datenbank wird nicht mehr über eine klassische, interaktive Installationsmaske konfiguriert, sondern zeitgemäß über Umgebungsvariablen (Environment Variables) in einer .env.local Datei abgebildet. Dies entspricht dem Symfony-Standard und erlaubt eine nahtlose Trennung zwischen lokalen Entwicklungsdaten und produktiven Live-Systemen.
Praxis & Code: CLI-Installation und Datenbank-Migration
Stelle sicher, dass deine Docker-Container aus Teil 2 im Hintergrund aktiv sind (docker compose up -d). Öffne dein Ubuntu-Terminal und führe die folgenden Schritte aus, um das CMS zu installieren und zu initialisieren.
1# 1. Logge dich in den laufenden PHP-FPM Container ein
2docker compose exec php bash
3
4# 2. Wechsle in das Standard-Webverzeichnis des Containers
5cd /var/www/html
6
7# 3. Installiere die Contao Managed Edition über Composer im aktuellen Verzeichnis
8# Wir verwenden den Fallback über /tmp, um Konflikte mit bereits vorhandenen Dockerfiles zu vermeiden
9composer create-project contao/managed-edition:^5.3 /tmp/contao-temp --no-install --no-scripts
10cp -a /tmp/contao-temp/. .
11rm -rf /tmp/contao-temp
12
13# 4. Generiere die composer.lock und installiere alle Kernabhängigkeiten
14composer install --no-cache
15
16# 5. Verlasse den Container temporär, um die Umgebungsvariablen anzulegen
17exitErstelle nun im Stammverzeichnis deines Backends (~/workspace/contao-headless-masterclass/contao-backend/) die Datei .env.local und definiere den standardisierten Datenbank-Connection-String sowie das Anwendungsgeheimnis:
Datei: contao-backend/.env.local
1# Symfony App-Umgebung und Secret Key
2APP_ENV=dev
3APP_SECRET=3c58b20ed5199e1778d44c2e4d1c2a5f # Ersetze dies durch einen zufälligen 32-stelligen Hex-Wert
4
5# Datenbank-Verbindung passend zu unserem MariaDB-Container aus Teil 2
6DATABASE_URL="mysql://contao:contao_password@db:3306/contao?serverVersion=10.11&charset=utf8mb4"Kehre nun in das Terminal des PHP-Containers zurück, um die Tabellenstrukturen zu generieren und deinen Administrator-Account anzulegen:
1# Erneut in den PHP-Container wechseln
2docker compose exec php bash
3
4# 6. Datenbank-Migrationen ausführen (Tabellen anlegen)
5vendor/bin/contao-console contao:migrate
6
7# 7. Den ersten Backend-Administrator anlegen
8vendor/bin/contao-console contao:user:createFolge den interaktiven Eingabeaufforderungen im Terminal, um deinen Benutzernamen, deine E-Mail-Adresse und dein sicheres Passwort festzulegen.
Dein System ist nun über das Nginx-Gateway erreichbar. Du kannst dich unter http://localhost:8080/contao mit deinen eben erstellten Zugangsdaten einloggen. Das Backend präsentiert sich vollständig leer und wartet auf die Konfiguration der API-Strukturen.

Die CORS-Härtung (Cross-Origin Resource Sharing)
Wenn du eine entkoppelte Architektur betreibst, stößt du unweigerlich auf eines der restriktivsten Sicherheitskonzepte moderner Webbrowser: die Same-Origin Policy. Dein Next.js-Frontend läuft lokal unter http://localhost:3000, während deine Contao-API unter http://localhost:8080 antwortet. Da sich die Ports (und später in Produktion die Domains) unterscheiden, wird der Browser jeden Fetch-Request blockieren und eine rote "CORS-Error"-Fehlermeldung in der Konsole werfen.
Das Backend (Contao) muss dem Browser des Nutzers explizit mitteilen, dass das Frontend (Next.js) berechtigt ist, diese Daten abzufragen. Um dies in Symfony/Contao auf Enterprise-Niveau abzubilden, nutzen wir nicht etwa einfache .htaccess-Hacks, sondern das branchenübliche nelmio/cors-bundle. Es erlaubt uns, präzise und umgebungsspezifische (Dev vs. Prod) Zugriffsregeln für unsere API-Routen zu definieren.
Wir richten dieses Bundle nun so ein, dass unsere API-Routen (/api/*) für unser Frontend freigeschaltet werden.
Praxis & Code: Nelmio CORS Bundle installieren und konfigurieren
Stelle sicher, dass deine Docker-Container laufen. Wir loggen uns in den PHP-Container ein, um das Bundle über Composer zu installieren.
1# 1. Logge dich in den PHP-Container ein
2docker compose exec php bash
3
4# 2. Installiere das Nelmio CORS Bundle
5composer require nelmio/cors-bundle
6
7# 3. Cache leeren und Container wieder verlassen
8vendor/bin/contao-console cache:clear
9exitNach der Installation müssen wir dem Bundle mitteilen, wie es sich verhalten soll. Symfony konfiguriert Bundles im Verzeichnis config/packages/. Erstelle in deinem Backend-Ordner diese Datei:
Datei: workspace/contao-headless-masterclass/contao-backend/config/packages/nelmio_cors.yaml
1nelmio_cors:
2 defaults:
3 origin_regex: true
4 allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
5 allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
6 allow_headers: ['Content-Type', 'Authorization', 'Accept', 'Origin']
7 expose_headers: ['Link']
8 max_age: 3600
9 paths:
10 # Wir definieren explizit, dass CORS nur für unsere Headless-API-Routen greift
11 '^/api/':
12 allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
13 allow_headers: ['*']
14 allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'OPTIONS']
15 max_age: 3600
16
17 # Das Contao Backend (/contao) bleibt streng isoliert!Wie du siehst, nutzen wir die Umgebungsvariable CORS_ALLOW_ORIGIN. Das ist entscheidend, denn lokal wollen wir localhost:3000 erlauben, auf dem Live-Server später aber z. B. https://meine-nextjs-app.de.
Öffne nun deine .env.local Datei (die wir in Iteration 1 angelegt haben) und füge die CORS-Regel für deine lokale Entwicklungsumgebung hinzu:
Datei: contao-backend/.env.local (Erweiterung)
# ... bestehende Variablen (APP_ENV, DATABASE_URL etc.)
# Erlaube Zugriffe von localhost (Next.js Dev-Server)
# Regex erlaubt http://localhost:3000 oder 127.0.0.1
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'Um die Änderungen wirksam zu machen, leere den Symfony-Cache ein letztes Mal:
docker compose exec php vendor/bin/contao-console cache:clearDeine Contao-Instanz ist nun sicher konfiguriert. Sie wird alle Anfragen an das klassische Backend (/contao) streng nach der Same-Origin Policy behandeln, aber Anfragen an /api/ mit den korrekten Headern (Access-Control-Allow-Origin) beantworten, sodass Next.js reibungslos Daten abrufen kann.

Das Nadelöhr der Performance – Bildgrößen Headless steuern
Bilder sind historisch gesehen der größte Flaschenhals für die Web-Performance und der Hauptgrund für verfehlte Core Web Vitals (insbesondere den LCP - Largest Contentful Paint). In einer klassischen Contao-Installation definierst du Bildgrößen im Backend, und Contao rendert automatisch das fertige <picture>-Element mit allen srcset-Attributen in das HTML-Template.
In einer Headless-Architektur funktioniert das nicht. Die API liefert nur JSON. Wenn die API dem Next.js-Frontend nur den rohen Pfad zum Originalbild (/files/images/hero-8k.jpg) übergibt, muss das Frontend entweder dieses riesige Bild laden oder es zur Laufzeit mühsam selbst berechnen. Beides ist ineffizient.
Der Best-Practice-Ansatz verlangt, dass Contao die harte Arbeit der Bildberechnung (Cropping, Resizing, WebP-Konvertierung) übernimmt. Die API muss dem Frontend ein strukturiertes JSON-Objekt liefern, das die URLs der bereits fertig zugeschnittenen Bilder für verschiedene Breakpoints enthält.
Um dieses Setup Enterprise-ready und versionskontrollierbar (Git) zu machen, klicken wir uns die Bildgrößen nicht mühsam im Contao-Backend zusammen. Stattdessen nutzen wir die config.yaml, um Bildgrößen updatesicher direkt im Code zu definieren. Das garantiert, dass alle Entwickler und der Live-Server exakt dieselben Bild-Spezifikationen verwenden.
Praxis & Code: Bildgrößen in der config.yaml definieren
Wir legen nun eine zentrale Konfigurationsdatei für Contao an. Hier definieren wir exemplarisch zwei Bildgrößen: Ein großes, responsives Hero-Bild (mit unterschiedlichen Zuschnitten für Mobile und Desktop) und ein Standard-Bild für Inhaltsspalten.
Erstelle im Backend-Ordner die Datei config.yaml (falls das Verzeichnis config noch nicht existiert, lege es an):
Datei: workspace/contao-headless-masterclass/contao-backend/config/config.yaml
1contao:
2 image:
3 sizes:
4 # 1. Bildgröße: Das Hero-Bild (Header)
5 # Auf dem Smartphone: 4:3 Hochformat, auf dem Desktop: 21:9 Panorama
6 hero_image:
7 width: 800
8 height: 600
9 resize_mode: crop
10 formats: ['webp', 'jpg'] # WebP bevorzugen, JPG als Fallback
11 items:
12 # Media Query für Bildschirme ab 768px (Tablets & Desktop)
13 - width: 1920
14 height: 820
15 resize_mode: crop
16 media: '(min-width: 768px)'
17
18 # 2. Bildgröße: Standard Content-Bild (z.B. für Text-Bild-Elemente)
19 content_image:
20 width: 600
21 height: 400
22 resize_mode: crop
23 formats: ['webp', 'jpg']
24 densities: '1.5x, 2x' # Retina-Support aktivierenSobald du diese Datei speicherst, registriert Contao diese Bildgrößen. Um Contao mitzuteilen, dass es die Konfiguration neu einlesen soll, müssen wir den Anwendungs-Cache leeren.
Öffne dein Terminal und führe im Hauptverzeichnis aus:
docker compose exec php vendor/bin/contao-console cache:clearWas haben wir damit erreicht? Diese Bildgrößen (hero_image und content_image) stehen nun systemweit in Contao zur Verfügung. Wenn ein Redakteur im Backend ein Bild-Element anlegt, kann er diese Größen im Dropdown-Menü auswählen. Viel wichtiger ist jedoch: Wir können diese vordefinierten Größen nun über unsere API ansteuern!
Das mächtige Contao Image Studio (verfügbar ab Contao 4.13/5.x) erlaubt es uns, eine Datei-UUID und einen dieser Konfigurations-Keys (_hero_image) zu übergeben und erhält im Gegenzug alle vorberechneten Pfade für das Frontend.

Der Image-Serializer – Von der Datenbank zum Frontend-Payload
Wir haben im vorherigen Schritt unsere responsiven Bildgrößen (hero_image und content_image) updatesicher in der config.yaml definiert. Wenn ein Redakteur in Contao ein Bild einfügt, speichert die Datenbank jedoch nur eine binäre UUID (z. B. 0x1234abcd...), die auf das Originalbild im Dateisystem verweist.
Das Next.js-Frontend kann mit dieser UUID absolut nichts anfangen. Es benötigt einen fertigen, sauber formatierten JSON-Datensatz, der alle berechneten Pfade für das <picture>-Element enthält: das Fallback-Bild (src), das srcset für Retina-Displays und die <source>-Knoten für die verschiedenen Media-Queries (z. B. WebP für den Desktop, JPG für Mobile).
Um diese komplexe Logik nicht in unseren Controllern zu verstreuen, bauen wir einen dedizierten Image-Serializer. Wir nutzen dafür das mächtige Contao Image Studio (eingeführt in neueren Contao 4/5 Versionen). Das Studio nimmt die rohe UUID und unseren Konfigurations-Key entgegen, berechnet im Hintergrund bei Bedarf die Bilder (Cropping, Resizing) und liefert uns ein Figure-Objekt zurück, das wir in ein sauberes PHP-Array umwandeln.
Praxis & Code: Den Image-Serializer als Symfony Service schreiben
Erstelle in deinem Backend-Ordner das Verzeichnis src/Serializer/ (falls noch nicht geschehen) und lege dort die Datei ImageSerializer.php an.
Datei: workspace/contao-headless-masterclass/contao-backend/src/Serializer/ImageSerializer.php
1<?php
2
3namespace App\Serializer;
4
5use Contao\CoreBundle\Image\Studio\Studio;
6use Contao\CoreBundle\Image\Studio\Figure;
7
8class ImageSerializer
9{
10 // Wir injizieren das Contao Image Studio über den Konstruktor (Dependency Injection)
11 public function __construct(private Studio $studio)
12 {
13 }
14
15 /**
16 * Serialisiert eine binäre UUID oder einen Dateipfad in ein API-taugliches Array
17 * * @param string $uuid Die binäre UUID aus der Datenbank (z.B. $contentModel->singleSRC)
18 * @param string $sizeConfig Der Key aus der config.yaml (z.B. '_hero_image')
19 */
20 public function serialize($uuid, string $sizeConfig = null): ?array
21 {
22 if (!$uuid) {
23 return null;
24 }
25
26 // 1. Das FigureBuilder-Objekt initialisieren
27 $builder = $this->studio->createFigureBuilder()->from($uuid);
28
29 // 2. Die Bildgröße zuweisen, falls ein Config-Key übergeben wurde
30 if ($sizeConfig) {
31 // Wichtig: Contao erwartet bei config.yaml Keys einen führenden Unterstrich
32 $builder->setSize('_' . $sizeConfig);
33 }
34
35 // 3. Das Figure-Objekt bauen (berechnet die Bilder, falls noch nicht geschehen)
36 $figure = $builder->buildIfResourceExists();
37
38 if (!$figure) {
39 return null;
40 }
41
42 // 4. Das Picture-Objekt und die Metadaten extrahieren
43 $picture = $figure->getPicture();
44 $metadata = $figure->getMetadata();
45
46 // 5. Die <source>-Knoten für responsive Media-Queries aufbereiten
47 $sources = [];
48 foreach ($picture->getSources() as $source) {
49 $sources[] = [
50 'srcset' => $source->getSrcset(),
51 'media' => $source->getMedia(),
52 'type' => $source->getMimeType(),
53 'sizes' => $source->getSizes(),
54 ];
55 }
56
57 // 6. Den finalen Payload für das Next.js Frontend zurückgeben
58 return [
59 // Das Fallback-Bild (wichtig für den <img> Tag in Next.js)
60 'src' => $picture->getImg()->getSrc(),
61 'srcset' => $picture->getImg()->getSrcset(),
62 'width' => $picture->getImg()->getWidth(),
63 'height' => $picture->getImg()->getHeight(),
64
65 // Metadaten (wichtig für SEO und Accessibility)
66 'alt' => $metadata ? $metadata->getAlt() : '',
67 'title' => $metadata ? $metadata->getTitle() : '',
68 'caption' => $metadata ? $metadata->getCaption() : '',
69
70 // Die responsiven Quellen für das <picture> Element
71 'sources' => $sources,
72 ];
73 }
74}Wie verwenden wir diesen Serializer jetzt in der Praxis?
Wenn wir später unsere API-Routen bauen, können wir diesen Service einfach in unseren Controller injizieren und verwenden. Ein Aufruf sähe dann logisch so aus:
// Beispielhafter Aufruf im API-Controller:
// Wir übergeben die UUID aus dem Datenbank-Model und den Namen unserer config.yaml Bildgröße
$imagePayload = $this->imageSerializer->serialize($contentModel->singleSRC, 'hero_image');Das Ergebnis, das Next.js später über die API erhält, ist ein perfekt strukturiertes JSON-Objekt. Next.js muss keine einzige Bildberechnung mehr durchführen, sondern kann die gelieferten src, width und sources direkt in native React-Komponenten "durchreichen". Das ist der absolute Schlüssel für einen perfekten Largest Contentful Paint (LCP).

Systemverifizierung und Fazit zum Basis-Setup
Wir haben in diesem dritten Teil der Masterclass das nackte Infrastruktur-Fundament in ein funktionierendes, hochspezialisiertes Headless-CMS verwandelt. Anstatt uns auf Klick-Wizards im Backend zu verlassen, haben wir Contao kompromisslos für den Enterprise-Betrieb konfiguriert.
Lass uns rekapitulieren, was wir erreicht haben – und warum das für die Performance deines Frontends später den entscheidenden Unterschied machen wird:
Die CLI-Installation: Wir haben Contao direkt im Container installiert und die Datenbank über sichere Umgebungsvariablen (
.env.local) verbunden. Das System ist sauber, versionierbar und bereit für professionelle CI/CD-Pipelines.Die CORS-Härtung: Durch das
nelmio/cors-bundlehaben wir eine strikte Sicherheitsbarriere errichtet. Unser Backend wehrt unbefugte Anfragen ab, erlaubt aber unserem Next.js-Frontend (und später der Live-Domain) den exklusiven, reibungslosen Datenabruf über die API-Routen.Die Bild-Architektur: Wir haben das größte Performance-Nadelöhr (den Largest Contentful Paint) proaktiv gelöst. Responsive Bildgrößen (
hero_image,content_image) sind nun updatesicher in derconfig.yamlverankert.Der Image-Serializer: Mit unserem neuen Symfony-Service (dem
ImageSerializer) haben wir die Brücke zwischen der Datenbank und der API geschlagen. Contao liefert Next.js nun keine toten Datei-UUIDs mehr, sondern fertig berechnete, responsive<picture>-Datenstrukturen im JSON-Format.
Praxis: Den Serializer in der Konsole testen
Da wir unsere eigentlichen API-Controller erst im nächsten Teil der Masterclass bauen, können wir unseren neuen Image-Serializer nicht direkt im Browser aufrufen. Wenn du jedoch sicherstellen willst, dass der Code fehlerfrei läuft, kannst du das Symfony-Terminal nutzen.
Wenn du dich in deinen PHP-Container einloggst (docker compose exec php bash), kannst du dir alle registrierten Services (inklusive unseres neuen Serializers) anzeigen lassen, um zu verifizieren, dass Symfony ihn korrekt geladen hat:
# Zeigt alle verfügbaren Services in Contao/Symfony an und filtert nach unserem Serializer
vendor/bin/contao-console debug:container App\Serializer\ImageSerializerGibt die Konsole hier die Details zu unserer Klasse aus, ist das System perfekt konfiguriert. Der Dependency Injection Container von Symfony hat unseren Code erkannt und die Studio-Klasse von Contao erfolgreich injiziert.
Das Backend ist nun bereit, seine Daten preiszugeben. ***

Häufig gestellte Fragen (FAQ)
In einem Enterprise-Umfeld und bei entkoppelten Architekturen ist Reproduzierbarkeit oberstes Gebot. Der grafische Contao Manager ist fantastisch für Standard-Websites, aber eine CLI-gestützte Installation über Composer stellt sicher, dass sich das Setup nahtlos in automatisierte CI/CD-Pipelines (Continuous Integration/Continuous Deployment) und Docker-Build-Prozesse integrieren lässt. Du hast die absolute Kontrolle über jeden Schritt.
Das ist ein absolut beabsichtigter Sicherheitsmechanismus deines Browsers (Same-Origin Policy). Selbst wenn beide Systeme auf localhost laufen, nutzen sie unterschiedliche Ports (z. B. 3000 für Next.js und 8080 für Contao). Für den Browser sind das zwei völlig fremde Welten. Das nelmio/cors-bundle teilt dem Browser über HTTP-Header explizit mit, dass dein Next.js-Frontend die Erlaubnis hat, die Contao-API auszulesen.
Die native next/image Komponente ist extrem mächtig und übernimmt die finale Optimierung auf dem Frontend-Server. Wenn die Contao-API jedoch nur den Pfad zum originalen 15-Megabyte-Bild liefert, muss Next.js dieses riesige Asset erst herunterladen und parsen. Indem Contao die responsiven Zuschnitte und WebP-Konvertierungen bereits vorab berechnet und als fertiges <picture>-JSON übergibt, entlasten wir den Node.js-Server massiv und garantieren perfekte Time-to-First-Byte (TTFB) und LCP-Werte.
Contao löst Dateireferenzen über binäre UUIDs (Universally Unique Identifiers) auf. Wenn das Bild im Dateisystem gelöscht wird, die UUID aber noch im Datenbankeintrag eines Text-Elements steht, fängt unser ImageSerializer dies elegant ab. Der Methodenaufruf buildIfResourceExists() des Image Studios liefert dann null zurück, und das Frontend rendert das Element einfach nicht, anstatt mit einem fatalen 500-Error abzustürzen.
Dein nächster Schritt: Datenstrukturen & das Redakteurs-Erlebnis
Unsere Infrastruktur steht, die Sicherheit ist konfiguriert und die Bild-Pipeline ist bereit für massiven Traffic. Doch das beste Headless-System nützt nichts, wenn die Redakteure nicht damit arbeiten können. Der größte Kritikpunkt an entkoppelten Architekturen ist oft ein stark eingeschränktes, unflexibles Backend-Erlebnis. Das ändern wir jetzt.
Im vierten Teil unserer Masterclass füllen wir die leere Datenbank mit echtem Leben und bauen die inhaltliche Architektur auf.
Wir beleuchten, wie du den Contao-Seitenbaum, Artikel und Inhaltselemente so strukturierst, dass sie logisch und sauber als JSON konsumiert werden können, ohne die Redakteure in ihrer gewohnten Arbeitsweise einzuschränken. Darüber hinaus zeigen wir dir die Best Practices, wie du eigene, komplexe Module und Formulare anlegst, deren Datenstruktur exakt auf die Verarbeitung im Next.js App-Router zugeschnitten ist.
Jetzt starten: Teil 4 – Datenstruktur & Redakteurs-Erlebnis im Headless-Kontext

Dietrich Bojko
Senior Webentwickler
Webinteger arbeitet seit vielen Jahren produktiv mit
Linux-basierten Entwicklungsumgebungen unter Windows.
Der Fokus liegt auf
performanten Setups mit WSL 2, Docker, PHP, Node.js und modernen
Build-Tools in realen Projekten –
nicht auf theoretischen Beispielkonfigurationen.
Die Artikel dieser Serie entstehen direkt aus dem täglichen Einsatz in Kunden- und Eigenprojekten und dokumentieren bewusst auch typische Fehler, Engpässe und bewährte Workarounds.


