fepli
EntwicklerSelbst hostenEnterprise

Produktivbetrieb

Im Produktivbetrieb läuft fepli aus demselben Image wie auf Ihrem Rechner. Anders ist, was darum herum liegt: TLS, echte Geheimnisse, Backups und ein Ablauf für Updates. Diese Seite zeigt ein vollständiges Setup auf einem Server mit Docker Compose und was Sie auf anderen Plattformen beachten.

Das Image

fepli baut das Image in seinem Release-Workflow mit Railpack und veröffentlicht es in der GitHub Container Registry als ghcr.io/ferienpass/app:general-latest. Es enthält PHP, den Webserver (FrankenPHP), alle Abhängigkeiten und die kompilierten Assets. Sie bauen es nie selbst.

Der Tag bewegt sich: Jedes Release ersetzt das Image dahinter. Um selbst zu bestimmen, wann ein Update stattfindet, deployen Sie per Digest. Nach einem Pull zeigt ihn docker images --digests ghcr.io/ferienpass/app, und Sie legen ihn so fest:

image: ghcr.io/ferienpass/app:general-latest@sha256:4f2a…

Das Varnish-Image, ghcr.io/ferienpass/varnish:latest, wird zusammen mit ihm veröffentlicht. Aktualisieren Sie beide gleichzeitig.

Das Image ist für linux/amd64 gebaut. Melden Sie sich auf jedem Host, der es lädt, bei der Registry an:

docker login ghcr.io --username <your user name>

Ein Server mit Docker Compose

Das Setup der lokalen Instanz, mit einem TLS-Proxy davor, Varnish zwischen Proxy und fepli, echten Geheimnissen und ohne Mail-Catcher. Legen Sie diese Dateien in einen Ordner auf dem Server. Um ohne Varnish zu laufen, nehmen Sie einen Dienst heraus.

compose.yaml

name: fepli

x-fepli: &fepli
  image: ghcr.io/ferienpass/app:general-latest
  env_file: fepli.env
  restart: unless-stopped
  depends_on:
    mariadb:
      condition: service_healthy
    redis:
      condition: service_started
  volumes:
    - files:/app/files
    - storage:/app/storage
    - images:/app/contao-assets/images
    - deferred-images:/app/var/deferred-images

services:
  proxy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
      - '443:443/udp'
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - varnish

  varnish:
    image: ghcr.io/ferienpass/varnish:latest
    restart: unless-stopped
    env_file: varnish.env
    environment:
      VARNISH_STORAGE: malloc,1g
    depends_on:
      - web

  web:
    <<: *fepli
    # Der Port öffnet sich, sobald die Datenbank migriert ist; Worker und Cron warten darauf.
    healthcheck:
      test: ['CMD', 'php', '-r', 'exit(@fsockopen("127.0.0.1", (int) (getenv("PORT") ?: 80)) ? 0 : 1);']
      interval: 10s
      start_period: 10m

  worker:
    <<: *fepli
    environment:
      IS_WORKER: '1'
    depends_on:
      web:
        condition: service_healthy

  cron:
    <<: *fepli
    command: ['while true; do php bin/console contao:cron; sleep 60; done']
    depends_on:
      web:
        condition: service_healthy

  mariadb:
    image: mariadb:12.3
    restart: unless-stopped
    env_file: mariadb.env
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --innodb-buffer-pool-size=1G
    healthcheck:
      test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
      interval: 10s
      retries: 10
    volumes:
      - db:/var/lib/mysql

  redis:
    image: redis:8
    restart: unless-stopped
    command: ['redis-server', '--appendonly', 'yes', '--maxmemory-policy', 'noeviction']
    volumes:
      - redis:/data

volumes:
  caddy-data:
  caddy-config:
  db:
  redis:
  files:
  storage:
  images:
  deferred-images:

Caddyfile

{
	email it@example.fepli.eu
}

example.fepli.eu, www.example.fepli.eu {
	# Nur fepli selbst darf den Cache leeren, von innerhalb des Netzes.
	@invalidation method BAN PURGE PURGEKEYS
	respond @invalidation 405

	reverse_proxy varnish:80
}

mariadb.env

MARIADB_ROOT_PASSWORD=…
MARIADB_DATABASE=fepli
MARIADB_USER=fepli
MARIADB_PASSWORD=…

varnish.env

# openssl rand -hex 32, derselbe Wert wie in fepli.env
VARNISH_PURGE_TOKEN=5b0e…

fepli.env ist das vollständige Beispiel aus der Konfiguration, mit Ihrer Domain, Ihrem Mailserver, dem Passwort aus mariadb.env in DATABASE_URL und dem Token aus varnish.env in VARNISH_PURGE_TOKEN.

Anmerkungen zu den einzelnen Teilen:

  • Caddy bezieht und erneuert die Zertifikate von Let's Encrypt selbstständig, sobald die Domains auf den Server zeigen. Es reicht den ursprünglichen Host und das Schema weiter, die fepli braucht, um korrekte Links zu bauen. Jeder andere Proxy funktioniert, wenn er dasselbe tut: Er muss X-Forwarded-Proto setzen, den Header Host beibehalten und Uploads bis 32 MB annehmen (nginx: client_max_body_size 32m).
  • Weitergeleitete Header. fepli entnimmt die Adresse des Clients und das Schema den Headern X-Forwarded-For und X-Forwarded-Proto von Anfragen, die von den Adressen in TRUSTED_PROXIES kommen. Der Standard deckt die Netze ab, die Docker einem Compose-Projekt gibt, deshalb müssen Sie die Variable in diesem Setup nicht setzen. fepli ignoriert die übrigen X-Forwarded-*-Header und entnimmt den Host aus Host. Der Proxy muss diese beiden Header daher selbst setzen und darf nicht weiterreichen, was ein Client sendet. Caddy tut das.
  • Varnish leitet jede Anfrage an den Dienst web weiter, egal für welche Domain. Es entfernt X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix und Forwarded aus jeder Anfrage und ignoriert Cache-Control in Anfragen ohne das Purge-Token: Auch ein erzwungenes Neuladen im Browser erhält die zwischengespeicherte Seite. VARNISH_STORAGE ist die Größe seines Caches, 256 MB, wenn Sie sie weglassen. Mit einem größeren Cache liefert Varnish mehr Seiten aus, ohne fepli zu fragen. Varnish braucht VARNISH_HOST=varnish:80 in fepli.env.
  • Purges. Varnish akzeptiert BAN, PURGE und PURGEKEYS nur aus dem privaten Netz und, mit VARNISH_PURGE_TOKEN in varnish.env, nur wenn sie dieses Token tragen. fepli sendet es aus fepli.env, beide Dateien brauchen also denselben Wert: Steht das Token nur bei Varnish, scheitert jeder Purge. Auch Ihr Proxy verbindet sich aus dem privaten Netz, deshalb weist er die Purge-Methoden selbst ab.
  • MariaDB: Geben Sie dem Buffer-Pool etwa die Hälfte des Arbeitsspeichers, den Sie MariaDB geben.
  • Nur der Proxy veröffentlicht Ports. MariaDB, Redis, Varnish und fepli sind nur aus dem Compose-Netz erreichbar.

Für mehrere Mandanten führen Sie im Caddyfile alle ihre Domains und die Plattform-Konsole auf, durch Kommas getrennt, wie bei Elmsbrück.

Ohne Varnish

So laufen Sie ohne Varnish (siehe Mit oder ohne Varnish):

  1. Löschen Sie den Dienst varnish aus compose.yaml und lassen Sie den Proxy stattdessen von web abhängen. varnish.env brauchen Sie nicht mehr.
  2. Leiten Sie im Caddyfile die Anfragen direkt an fepli: reverse_proxy web:80.
  3. Setzen Sie in fepli.env VARNISH_HOST= (leer).

fepli purgt dann nichts. Um Varnish später zu ergänzen, machen Sie die drei Schritte rückgängig.

Erster Start

docker compose up -d

Der Web-Container legt die Tabellen von fepli an, bevor er antwortet, und Worker und Cron starten, sobald er antwortet. docker compose logs -f web zeigt den Fortschritt.

Legen Sie danach den Mandanten und seinen ersten Admin an, wie unter Ein oder mehrere Mandanten beschrieben. Richten Sie das DNS der Domains auf den Server, bevor Sie sie öffnen: Caddy braucht es, um die Zertifikate zu erhalten.

Updates

fepli kündigt im Changelog an, was ein Release ändert. Sichern Sie zuerst die Datenbank, dann:

Update

docker compose pull web worker cron varnish
docker compose stop web worker cron
docker compose up -d
  1. pull lädt die neuen fepli-Images herunter, während die alten weiterlaufen. Haben Sie einen Digest festgelegt, tragen Sie den neuen zuerst in compose.yaml ein. Lassen Sie varnish weg, wenn Sie ohne Varnish laufen. MariaDB, Redis und Caddy aktualisieren Sie nach Ihrem eigenen Zeitplan.
  2. stop nimmt fepli offline, damit kein alter Container die Datenbank verwendet, während die neue Version ihre Struktur ändert. Mit Varnish bleiben die Seiten erreichbar, die es hält; alles andere antwortet mit einem Fehler, bis der letzte Schritt abgeschlossen ist.
  3. up -d startet die neue Version. Der Web-Container migriert die Datenbank, bevor er antwortet: Er ergänzt, was die neue Version braucht, und entfernt, was sie nicht mehr verwendet. Mit Varnish leert er danach den Cache für jede Domain jedes Mandanten und für die Plattform-Konsole. Worker und Cron starten, sobald der Web-Container antwortet.

Scheitert die Migration, hält der Web-Container an, und Worker und Cron starten nicht. docker compose logs web zeigt den Grund. Beheben Sie die Ursache, oder kehren Sie zum vorherigen Image zurück und spielen Sie das Backup ein.

Um die Migration selbst auszuführen, setzen Sie MIGRATE_ON_START=0 in fepli.env und führen Sie sie zwischen stop und up -d aus. Behalten Sie die Anführungszeichen bei: Das Image führt seinen Befehl über bash -c aus, der ganze Befehl muss also ein einziges Argument sein.

docker compose run --rm web 'php bin/console app:migrate --with-deletes --no-backup'

Proben Sie ein Update auf Ihrer lokalen Instanz oder einer Staging-Instanz, bevor Sie es im Produktivbetrieb ausführen. Mit festgelegtem Digest erhält der Produktivbetrieb dann genau das Image, das Sie ausprobiert haben.

Backups

Sichern Sie zwei Dinge täglich und bewahren Sie die Kopien außerhalb des Servers auf. Die Kopien enthalten die Daten der Familien:

  • Verschlüsseln Sie sie, bevor sie den Server verlassen, mit einem Schlüssel, dessen privater Teil nicht auf dem Server liegt. Die Beispiele verwenden age und den öffentlichen Teil Ihres Backup-Schlüssels (age1…).
  • Legen Sie eine Aufbewahrungsfrist fest und löschen Sie ältere Kopien, wenn sie endet. Eine Kopie behält Daten, die fepli selbst löscht, wenn ihre Aufbewahrungsfrist endet.

Was Sie sichern:

  • Die Datenbank. Sie enthält alles außer Dateien. Erstellen Sie den Dump als Benutzer, der nur lesen darf, mit dem Passwort in einer Options-Datei statt in der Kommandozeile. Legen Sie den Benutzer einmal im MariaDB-Client an, der nach dem Root-Passwort aus mariadb.env fragt:

    docker compose exec mariadb mariadb -uroot -p
    
    CREATE USER 'backup'@'localhost' IDENTIFIED BY '…';
    GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES ON fepli.* TO 'backup'@'localhost';
    

    Tragen Sie das Passwort neben compose.yaml in backup.cnf ein, nur für Sie lesbar (chmod 600):

    backup.cnf

    [mariadb-dump]
    user=backup
    password=…
    

    Erstellen Sie dann den Dump, komprimieren und verschlüsseln Sie ihn. Der Dump liest backup.cnf aus seiner Eingabe:

    docker compose exec -T mariadb \
      mariadb-dump --defaults-extra-file=/dev/stdin --single-transaction fepli < backup.cnf \
      | gzip | age -r age1… > fepli-$(date +%F).sql.gz.age
    
  • Die Volumes. files und storage enthalten, was Personen hochgeladen und exportiert haben: Logos, Bilder von Angeboten, Exportdateien. Mit Objektspeicher liegen Anhänge sowie Logo und Anmeldebild jedes Mandanten stattdessen im Bucket für Dateien: Sichern Sie auch diesen Bucket. Die Belege und die Einwilligungsnachweise im Retention-Bucket, wo Sie einen genannt haben, sind Unterlagen, die Sie vorlegen können müssen: Nehmen Sie ihn ebenfalls auf, und den Uploads-Bucket. Die übrigen Volumes enthalten Vorschaubilder und den Suchindex; sichern Sie auch sie, wenn Sie lückenlos wiederherstellen möchten. Zum Beispiel:

    docker run --rm -v fepli_files:/data:ro alpine tar czf - -C /data . \
      | age -r age1… > files-$(date +%F).tgz.age
    

Redis enthält Aufträge, die noch nicht gelaufen sind, und die Sessions. Es hält sie auf der Platte, ein Neustart verliert also nichts, aber es braucht kein Backup.

Ein Backup, das Sie nie wiederhergestellt haben, ist eine Hoffnung, kein Backup. Stellen Sie hin und wieder eines in einer Instanz wieder her, die wie der Produktivbetrieb geschützt ist. Verwenden Sie dafür nicht Ihre lokale Instanz: Die Kopie enthält die Daten echter Familien.

Skalierung

Wenn ein Server mit dem obigen Setup ausgelastet ist:

  • Läuft die Instanz ohne Varnish? Ergänzen Sie es zuerst. Es beantwortet die meisten Seitenaufrufe, bevor sie den Web-Container erreichen.

  • Mehr Anfragen gleichzeitig: Erhöhen Sie FRANKENPHP_MAX_THREADS und geben Sie dem Web-Container den Arbeitsspeicher dafür, bis zu 512 MB pro Thread.

  • Mehr Hintergrundaufträge: Fügen Sie Worker-Container hinzu. Jeder braucht einen eigenen Consumer-Namen als letzten Teil seiner Warteschlangenadresse, sonst holen zwei Worker denselben Auftrag ab: MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages/symfony/worker-2.

  • Mehr als ein Web-Container: Das geht, sobald Sessions und Sperren in Redis liegen (SESSION_DSN und LOCK_DSN). Die Volumes müssen dann ein gemeinsamer Speicher sein, auf den alle schreiben können. Starten die Container gemeinsam, migrieren sie die Datenbank abwechselnd: Der erste erledigt die Arbeit, die anderen finden nichts mehr zu tun.

  • Der Cron: immer genau einer.

Andere Plattformen

Halten Sie sich auf Kubernetes, Nomad, Dokploy, Coolify und Ähnlichem an dieselbe Form:

  • Drei Workloads aus einem Image mit denselben Variablen: das Web (Standardbefehl, Port 80), der Worker (IS_WORKER=1) und der Cron (ein Replikat).
  • Das Image läuft als root. Geben Sie ihm keinen anderen Benutzer (Compose user:, Kubernetes runAsUser oder runAsNonRoot): Sein Startskript und der Webserver erwarten root.
  • Varnish sendet jede Anfrage an den Host web auf Port 80. Nennen Sie den Web-Dienst web oder geben Sie ihm diesen Alias. Geben Sie bei einem VARNISH_PURGE_TOKEN Varnish denselben Wert wie dem Web-, dem Worker- und dem Cron-Container.
  • Vertrauenswürdige Proxys. Auf Docker Swarm, Dokploy und Kubernetes verbinden sich Ihr Proxy und Varnish meist aus 10.0.0.0/8, außerhalb des Standards von TRUSTED_PROXIES. Setzen Sie die Variable auf das Netz, aus dem sie sich verbinden. Sonst hält fepli jede Anfrage für unverschlüsseltes HTTP, das von der Adresse des Proxys kommt.
  • Das Image führt seinen Befehl über /bin/bash -c aus. Um den Befehl zu ersetzen, übergeben Sie die ganze Befehlszeile als ein Argument. Setzen Sie auf Kubernetes args, nicht command: command ersetzt den Entrypoint.
  • Der Worker beendet sich alle fünf Minuten selbst, um mit frischem Speicher neu zu beginnen, und verlässt sich darauf, dass er neu gestartet wird. Plattformen, die vor jedem Neustart immer länger warten, wie Kubernetes, sollten ihn stattdessen in einer Schleife ausführen: args: ['while true; do php bin/console messenger:consume async --time-limit=300 --memory-limit=128M; done'].
  • Führen Sie den Cron als dauerhafte Schleife aus, wie in der Compose-Datei, und nicht als Job pro Minute, der jedes Mal einen neuen Container startet.
  • Volumes, die sich Web und Worker teilen, und ebenso alle Web-Replikate.
  • Der Web-Container migriert die Datenbank beim Start, bevor er seinen Port öffnet. Geben Sie ihm eine Startup-Probe mit genug Zeit dafür, z. B. eine TCP-Probe auf Port 80, die zehn Minuten erlaubt. Rollen Sie ein Update aus, indem Sie zuerst die alten Container stoppen (auf Kubernetes strategy: Recreate): Die Migration entfernt Spalten, die die alte Version noch liest. Um stattdessen in einem eigenen Job zu migrieren, setzen Sie MIGRATE_ON_START=0 und führen Sie php bin/console app:migrate --with-deletes --no-backup aus, bevor die neuen Container starten.
  • Einmalige Befehle führen Sie im Web-Container aus: mit kubectl exec oder im Terminal der Plattform.

Zustellberichte von Brevo

E-Mails verschickt fepli über MAILER_DSN: SMTP, sendmail oder die API von Brevo. Gehen die E-Mails über Brevo, kann Brevo melden, was aus jeder E-Mail wurde: zugestellt, zurückgestellt, abgewiesen, blockiert, als Spam gemeldet, abgemeldet, geöffnet, geklickt. Registrieren Sie den Webhook für diese Berichte mit dem Brevo-Konto von BREVO_API_KEY:

docker compose exec web php bin/console brevo:engagement

Der Webhook zeigt auf die Domain in APP_BASE_URL, die Brevo über HTTPS erreichen muss. Auch eine Installation mit mehreren Mandanten braucht ihn nur einmal: Der Webhook zeigt dann auf die Plattform-Konsole, mit oder ohne --tenant, und fepli gibt jeden Bericht an den Mandanten weiter, der die E-Mail verschickt hat. Registrieren Sie keinen pro Mandant: Brevo sendet jeden Bericht an jeden Webhook des Kontos. Der Webhook fragt bei Brevo immer Zustellereignisse, Öffnungen und Klicks an. Was fepli davon festhält, entscheidet jeder Mandant mit der App Versand nachverfolgen unter Einstellungen → App-Store:

  • App aus (der Standard). fepli verwirft jeden Bericht. Der Postausgang des Admin-Panels zeigt nur, dass eine Nachricht versendet wurde, und der deliveryStatus der gesendeten Nachrichten der API bleibt sent. Eine Installation, die die Zustellung bisher erfasst hat, hält keine neuen Berichte fest, bis ein Mandant die App einschaltet: Keine Migration übernimmt das.
  • App an. fepli hält Zustellung, Abweisungen, Spam-Meldungen und Abmeldungen fest.
  • App an und die Einstellung Öffnungen und Klicks messen an. fepli hält auch Öffnungen und Klicks der Empfänger:innen fest, die gemessen werden dürfen. Ist die Einstellung Nur mit Einwilligung messen an, sind das nur Empfänger:innen, die in ihrem Konto zugestimmt haben. Ist sie aus, sind es alle, die nicht widersprochen haben.

Um eine Öffnung zu messen, wird auf dem Gerät der Empfänger:in ein Tracking-Pixel geladen (§ 25 TDDDG). Solange die App an ist, nennt fepli Brevo mit jeder E-Mail, ob Öffnungen und Klicks der Empfänger:in gemessen werden dürfen (contactPixelTrackingConsent in POST /v3/smtp/email), und trägt jede Entscheidung am Kontakt ein (_PIXEL_TRACKING_CONSENT). Damit Brevo das beachtet, schalten Sie in Ihrem Brevo-Konto Per-contact pixel tracking consent ein (Settings → Contacts). Brevo lässt das Pixel dann nicht weg, sondern zählt Öffnung und Klick ohne die Person, wo nicht gemessen werden darf.

Die Angabe je E-Mail gibt es nur über Brevos API (MAILER_DSN=brevo+api://…). Über SMTP erreicht sie Brevo nicht, und jede Empfänger:in gilt dort als unbekannt. Dann entscheidet Track contacts whose consent is unknown im Brevo-Konto: Stellen Sie es auf No, wenn Nur mit Einwilligung messen an ist. Mit Yes misst Brevo auch, wer widersprochen hat; fepli verwirft dessen Öffnungen und Klicks trotzdem.

Ein Webhook, der mit einer früheren Version registriert wurde, fragt möglicherweise andere Ereignisse an. Der Befehl meldet das dann: Führen Sie ihn erneut mit --force aus, um ihn zu ersetzen. Der Konfigurationsschlüssel ferienpass.mail_tracking ist veraltet und wird nicht mehr gelesen. php bin/console mail:purge-tracking zählt die bisher erfassten Öffnungen und Klicks und löscht sie mit --force.

Überwachung

  • Logs: Jeder Container schreibt in seine Ausgabe. docker compose logs -f web worker cron zeigt sie.
  • Uptime: https://example.fepli.eu/health antwortet mit 200, solange der Web-Container funktioniert.
  • Cron: Setzen Sie STATUS_HEARTBEAT_URL auf die Heartbeat-Adresse eines Uptime-Monitors. Der Cron ruft sie jede Stunde auf; bleiben die Aufrufe aus, schlägt der Monitor Alarm.
  • Fehler: Sie stehen mit allem anderen in den Logs. fepli verschickt keine Fehlerberichte.
  • Der Worker: Wenn keine E-Mails mehr ankommen, sehen Sie zuerst in die Logs des Workers.

Sicherheits-Checkliste

  • Nur der Proxy ist von außen erreichbar. MariaDB, Redis und Varnish veröffentlichen nie einen Port.
  • TRUSTED_HOSTS nennt Ihre Domains und sonst nichts.
  • Der Proxy setzt X-Forwarded-For und X-Forwarded-Proto selbst, statt weiterzureichen, was Clients senden, und behält Host bei.
  • TRUSTED_PROXIES deckt die Netze ab, aus denen sich Ihr Proxy und Varnish verbinden: in diesem Compose-Setup der Standard, anderswo Ihr eigener Wert.
  • Mit Varnish weist der Proxy BAN-, PURGE- und PURGEKEYS-Anfragen von außen ab, und Varnish und fepli teilen ein eigenes VARNISH_PURGE_TOKEN.
  • Nur das Varnish von fepli legt Seiten von fepli im Cache ab. Ein CDN davor schadet nicht, speichert aber ebenfalls nichts von fepli.
  • APP_SECRET und INTEGRATIONS_ENCRYPTION_KEY sind Ihre eigenen, und fepli.env, mariadb.env und varnish.env sind nur für Sie lesbar.
  • ADMIN_EMAIL ist eine Adresse von Ihnen.
  • APP_DEBUG bleibt 0.
  • Sie spielen neue Releases zeitnah ein: Sicherheitskorrekturen erreichen Sie nur über neue Images.

War diese Seite hilfreich?