Multi-Environment-Konfiguration in Symfony und Next.js: Secrets, .env-Dateien und Produktionshygiene
Betreibe eine Symfony-API lange genug neben einem Next.js-Frontend und du triffst denselben Bug zweimal: ein Wert, der in einer App korrekt und in der anderen veraltet ist. Symfony Environment-Konfiguration und das env-Handling von Next.js lösen dasselbe Problem mit unterschiedlichen Regeln, und Teams, die beide als austauschbar behandeln, landen bei Drift oder einem Deployment, das stillschweigend die Staging-Datenbank-URL nach Produktion ausgeliefert hat.
Dieser Beitrag beschreibt eine Strategie, die beide Frameworks abdeckt: wie jede dotenv-Hierarchie tatsächlich aufgelöst wird, wohin verschlüsselte Secrets gehören und wie Werte auf Kamal oder Coolify in Produktion gelangen, ohne dass jemand sie per SSH auf einen Server kopiert.
Symfony Environment-Konfiguration: wie die .env-Hierarchie aufgelöst wird
Symfony liest bis zu vier Dateien in einer festen Reihenfolge, wobei spätere Dateien frühere überschreiben:
.env, committet, enthält sichere Defaults für jede Umgebung.env.local, von Git ignoriert, enthält deine maschinenspezifischen Overrides.env.$APP_ENV(zum Beispiel.env.test), committet, enthält Defaults pro Umgebung.env.$APP_ENV.local, von Git ignoriert, enthält Overrides pro Umgebung auf dieser Maschine
Eine Regel steht über allen vier Dateien: Eine echte Umgebungsvariable gewinnt immer. Ist DATABASE_URL in der Prozessumgebung gesetzt, kann nichts in irgendeiner .env-Datei sie überschreiben. Das ist die Eigenschaft, an der die gesamte Produktionsstrategie hängt, denn sie bedeutet, dass die Deploy-Plattform die echten Werte injizieren kann und die Dateien im Repository harmlos bleiben.
Zwei Hinweise für Produktion. Erstens: .env.local wird bei APP_ENV=test komplett übersprungen, was Leute überrascht, deren Testsuite plötzlich ihren lokalen Datenbank-Override ignoriert. Zweitens: Führe composer dump-env prod während deines Builds aus. Es kompiliert die gesamte Hierarchie in eine einzige .env.local.php-Datei, sodass Symfony das Parsen der dotenv-Dateien bei jedem Request überspringt. Bei einer stark frequentierten API zählt das, weil es Datei-I/O aus dem Hot Path entfernt.
Next.js spielt nach anderen Regeln, und der Unterschied beißt zur Build-Zeit
Next.js liest ebenfalls .env, .env.local, .env.development und .env.production mit einer ähnlichen Override-Reihenfolge. Die Dateireihenfolge wird dir vertraut vorkommen. Die Falle ist, wann eine Variable gelesen wird.
Jede Variable mit dem Präfix NEXT_PUBLIC_ wird zur Build-Zeit ins JavaScript-Bundle eingebettet. Der Build backt sie als String-Literal in die kompilierte Ausgabe, sodass eine spätere Änderung der Container-Umgebung keinerlei Effekt darauf hat. Server-only-Variablen (ohne Präfix) liest Next.js zur Laufzeit wie jeder Node-Prozess.
Die praktische Konsequenz: Unterscheidet sich NEXT_PUBLIC_API_URL zwischen Staging und Produktion, kann ein Docker-Image nicht beide bedienen. Entweder baust du ein Image pro Umgebung, akzeptierst einen Workaround für Laufzeitkonfiguration (einen Endpunkt oder ein injiziertes Script, das die Config an den Client liefert) oder du hältst umgebungsspezifische Werte komplett aus NEXT_PUBLIC_ heraus und proxyst über Next.js Route Handler, die serverseitige Variablen zur Laufzeit lesen.
Teams, die von Symfony kommen, erwarten, dass "Env-Variable setzen, Container neu starten" überall funktioniert. In Next.js funktioniert es nur für serverseitige Variablen. Entscheide pro Variable, ob sie Build-Zeit oder Laufzeit ist, und halte diese Entscheidung als Kommentar direkt in der Datei fest. Dein zukünftiges Ich wird sich nicht erinnern.
Verschlüsselte Produktions-Secrets mit symfony/secrets
Alles Sensible sollte gar nicht in einer dotenv-Datei liegen. Der Secrets-Vault von Symfony verschlüsselt Werte mit einem öffentlichen Schlüssel, sodass sie sicher committet werden können:
php bin/console secrets:set DATABASE_PASSWORD --env=prod
Das schreibt eine verschlüsselte Datei unter config/secrets/prod/. Der öffentliche (Verschlüsselungs-)Schlüssel wird ebenfalls committet. Der private (Entschlüsselungs-)Schlüssel ist das Einzige, das niemals ins Repository darf. In Produktion stellst du ihn entweder als Datei config/secrets/prod/prod.decrypt.private.php bereit, die dein Deploy-Prozess platziert, oder als Umgebungsvariable SYMFONY_DECRYPTION_SECRET mit dem base64-kodierten Schlüssel. In CI speicherst du diesen Wert als maskiertes Secret und injizierst ihn nur in die Jobs, die ihn brauchen, was normalerweise den Deploy-Job und nicht die Test-Jobs bedeutet.
Rotation ohne Downtime beruht auf der Override-Regel von vorhin. Echte Umgebungsvariablen schlagen Vault-Werte, also ist die Reihenfolge:
- Setze den neuen Wert als einfache Umgebungsvariable auf der Plattform und deploye neu. Die App verwendet jetzt das neue Credential, während der Vault noch das alte enthält.
- Aktualisiere den Vault:
secrets:setmit dem neuen Wert, committen, deployen. - Entferne die temporäre Umgebungsvariable.
Leakt der Schlüssel selbst statt eines einzelnen Werts, verschlüsselt secrets:generate-keys --rotate den gesamten Vault in einem Schritt mit einem frischen Schlüsselpaar neu. Liefere den neuen privaten Schlüssel im selben Deployment nach Produktion, das den neu verschlüsselten Vault ausliefert, und alte Checkouts sind nicht mehr entschlüsselbar.
Keine echten Werte mehr in .env.example
Eine .env.example (oder die committete .env selbst) sollte Platzhalter und sichere lokale Defaults enthalten, sonst nichts. Der Fehlerfall ist immer derselbe: Jemand kopiert seine funktionierende .env.local über die Beispieldatei, "damit das Onboarding einfacher wird", committet sie, und ein echter API-Key liegt jetzt dauerhaft in der Git-Historie. Die Historie über jeden Clone und Fork hinweg umzuschreiben ist so mühsam, dass der Key in der Praxis als geleakt behandelt und rotiert werden muss.
Zwei günstige Absicherungen: ein Secret-Scanner wie Gitleaks in CI, der die Pipeline bei allem fehlschlagen lässt, was wie ein Credential aussieht, und eine Konvention, dass die Beispieldatei offensichtlich falsche Werte (changeme, sk_test_xxx) verwendet, damit ein echter Wert im Review auffällt.
Werte auf Kamal und Coolify in Produktion bringen
Beide Plattformen folgen demselben Prinzip: Das Repository definiert, welche Variablen existieren, die Plattform liefert, was sie enthalten.
Bei Kamal 2 werden Umgebungsvariablen in config/deploy.yml unter env deklariert, aufgeteilt in clear und secret. Secret-Werte stehen nicht im YAML; Kamal liest sie beim Deployment aus .kamal/secrets, einer script-artigen Datei, die Werte aus deiner Shell-Umgebung oder einer Passwortmanager-CLI (1Password, Bitwarden und Co.) ziehen kann. Die Werte landen in der Umgebung des Containers, genau dort, wo die Override-Regel von Symfony und die serverseitigen Lookups von Next.js sie erwarten.
Coolify verwaltet Umgebungsvariablen in seiner UI pro Anwendung. Das Detail, das für diesen Beitrag zählt, ist der Build-Variable-Toggle: Eine als Build-Variable markierte Variable ist während des Image-Builds verfügbar, was ein NEXT_PUBLIC_-Wert braucht, während Laufzeitvariablen nur im laufenden Container existieren. Diesen Toggle falsch zu setzen ist die mit Abstand häufigste Coolify-Fehlkonfiguration, die wir bei Next.js-Apps sehen: Der Build gelingt, die App läuft, und das Frontend ruft stillschweigend den falschen API-Host auf, weil der eingebackene Wert aus einem Fallback kam.
Eine Quelle der Wahrheit für gemeinsame Werte
Eine Symfony-API und ein Next.js-Frontend in derselben Umgebung teilen mehr Konfiguration, als Teams erwarten: die API-Basis-URL, die Cookie-Domain für Session oder JWT, CORS-Origins, den Stripe Publishable Key, das Sentry-DSN-Paar. Das Anti-Pattern ist, diese von Hand an zwei Stellen zu pflegen, eine pro App, und nach einem Vorfall festzustellen, dass Staging und Produktion drei Wochen lang bei der Cookie-Domain uneinig waren.
Definiere gemeinsame Werte einmal auf der Deploy-Ebene. In Kamal ist das ein YAML-Anchor oder ein gemeinsamer Abschnitt in .kamal/secrets; in Coolify ist es eine geteilte Variable auf Projekt- oder Umgebungsebene, auf die beide Anwendungen verweisen. Die Apps konsumieren dann denselben injizierten Wert unter ihrem eigenen Namen (APP_URL auf der Symfony-Seite, NEXT_PUBLIC_APP_URL oder ein serverseitiges Äquivalent auf der Next.js-Seite). Ändert sich der Wert, ändert er sich an einer Stelle, und beide Deployments übernehmen ihn.
CI-Injection und der Audit-Trail
Speichere Secrets in GitHub Actions auf Environment-Ebene statt auf Repository-Ebene und binde Deploy-Jobs an diese Environments. Das gibt dir zwei Dinge: Required Reviewers können Produktions-Deployments absichern, und jedes Environment hält nur seine eigenen Werte, sodass ein Workflow, der auf Staging zielt, das Produktions-Datenbankpasswort physisch nicht lesen kann.
jobs:
deploy:
environment: production
steps:
- run: bin/kamal deploy
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
SYMFONY_DECRYPTION_SECRET: ${{ secrets.SYMFONY_DECRYPTION_SECRET }}
Die Audit-Frage "Wer hat dieses Produktions-Secret wann geändert?" hat zwei Antworten, je nachdem, wo das Secret lebt. Werte im Symfony-Vault haben eine vollständige Git-Historie: Die verschlüsselte Datei hat sich in einem Commit geändert, mit Autor und Zeitstempel, überprüfbar in einem Pull Request. Werte in einer Plattform-UI oder in GitHub Secrets sind undurchsichtiger; GitHub protokolliert Secret-Updates im Audit-Log der Organisation, und Coolify zeigt einen Zuletzt-geändert-Status, aber keines von beiden sagt dir, was der vorherige Wert war. Unsere Faustregel: Secrets, die sich über einen Engineering-Prozess ändern (API-Keys, Signing-Keys), gehören in den Vault, wo die Änderung ein reviewter Commit ist, während Secrets im Besitz des Betriebs (von der Plattform verwaltete Datenbankpasswörter) auf der Plattform leben können, mit dem Audit-Log als Spur.
Häufig gestellte Fragen
Sollte der Entschlüsselungsschlüssel jemals in CI für Test-Jobs liegen? Normalerweise nein. Testumgebungen sollten mit Test-Credentials aus einfachen Umgebungsvariablen laufen. Ist der Entschlüsselungsschlüssel nur in Deploy-Jobs vorhanden, kann eine kompromittierte Test-Dependency keine Produktions-Secrets lesen.
Ist es überhaupt sicher, .env in Git zu committen? Die committete .env ist in Ordnung, solange sie Defaults enthält, die in einem öffentlichen GitHub-Repository harmlos wären. Sobald dich ein Wert bei einem Leak beunruhigen würde, gehört er in den Vault oder auf die Plattform, und die committete Datei behält einen Platzhalter.
Was ist mit Docker Compose für die lokale Entwicklung? Das env_file von Compose liest eine dotenv-Datei in die Container-Umgebung, die dann innerhalb des Containers die eigene Dateihierarchie von Symfony überstimmt. Wähle pro Projekt einen Mechanismus für die lokale Arbeit, entweder Compose-Injection oder das dotenv-Loading des Frameworks, und dokumentiere ihn, denn einen Wert zu debuggen, der über beide Wege ankommt, ist eine Qual.
Wo das üblicherweise schiefgeht
Konfigurationsdrift ist selten der Bug, der gemeldet wird. Er zeigt sich als CORS-Fehler nur auf Staging, als Webhook, der in einer Umgebung funktioniert, als Frontend, das nach einem Infrastrukturumzug die falsche API aufruft. Wenn wir ein Code-Audit für eine SaaS-Codebasis durchführen, ist das Environment- und Secrets-Setup eines der ersten Dinge, die wir kartieren, weil es viel darüber vorhersagt, wie das Team unter Druck arbeitet. Und wenn wir mit diesem Stack Anwendungen bauen, kommt die obige Konfigurationsstrategie in den ersten Sprint, statt auf eine spätere Hardening-Phase zu warten.
Wenn dein Symfony- und Next.js-Setup seine Konfiguration organisch gewachsen hat und du nicht mehr sicher bist, welche Werte wo leben, schreib uns an hello@wolf-tech.io oder schau dich auf wolf-tech.io um. Ein kurzes Review der Konfigurationsoberfläche reicht meist, um die riskanten Stellen zu finden.

