---
title: 'Dockerfile für PHP und Laravel: Best Practices | DevSense'
description: 'Wie Sie ein Production-Image für Laravel bauen: Multi-Stage-Dockerfile für PHP-FPM und Vite-Assets, Layer-Cache für Composer und npm, OPcache, Non-Root, Secrets, Healthcheck und ein Image für Web, Worker und Scheduler.'
faq:
    - { question: 'Kann man das Laravel-Sail-Image in Produktion verwenden?', answer: 'Nein. Das Sail-Image ist für die Entwicklung gedacht: Es enthält Xdebug, Node.js, Datenbank-Clients und weitere Werkzeuge, und die Anwendung läuft über php artisan serve – einen Single-Thread-Entwicklungsserver. Für die Produktion baut man ein separates, minimales Image auf Basis von PHP-FPM (oder FrankenPHP) ohne Dev-Abhängigkeiten.' }
    - { question: 'Wozu braucht man ein Multi-Stage-Dockerfile für PHP?', answer: 'Composer, Node.js, npm-Pakete und Compiler werden nur während des Builds gebraucht. Bei einem Multi-Stage-Build werden Abhängigkeiten und Assets in separaten Stages gebaut, und ins finale Image werden nur vendor/ und public/build kopiert. Das Image wird kleiner, enthält weniger angreifbare Software, und die Layer lassen sich besser cachen.' }
    - { question: 'Wann sollte php artisan config:cache laufen – beim Image-Build oder beim Containerstart?', answer: 'Beim Containerstart. config:cache brennt die Werte der Umgebungsvariablen in eine Datei ein. Geschieht das beim Build, landen die Variablen der Build-Umgebung im Image, und die Produktionseinstellungen, die dem Container übergeben werden, werden ignoriert. Dasselbe gilt für route:cache, wenn die Routen von der Konfiguration abhängen.' }
    - { question: 'Was wählt man für das PHP-Image: Alpine oder Debian?', answer: 'Alpine ist kleiner, verwendet aber musl statt glibc: Manche Extensions brauchen länger zum Kompilieren, und das Verhalten einzelner Bibliotheken sowie die Performance können abweichen. Das Debian-Image (Standard beim offiziellen php-Image) ist größer, dafür berechenbarer. Für die meisten Laravel-Projekte ist Debian die vernünftige Wahl, Alpine dann, wenn die Größe kritisch ist und Sie Ihre Anwendung unter musl getestet haben.' }
published: '2026-10-03'
---
# Dockerfile für PHP und Laravel: ein Production-Image ohne Ballast

Lokal haben Sie Sail: `sail up`, und alles läuft. Dann kommt die Produktion, und der schnellste Weg ist, dasselbe Image zu nehmen, `COPY . .` hinzuzufügen und es zu starten. Einen Monat später stellt sich heraus, dass das Image über ein Gigabyte groß ist, Xdebug und Node.js enthält, die Anwendung von `php artisan serve` in einem einzigen Thread ausgeliefert wird, jeder Build alle Composer-Pakete neu herunterlädt und die `.env` mit den Datenbankzugangsdaten direkt in einem Layer des Images liegt.

Ein gutes Production-Image für Laravel ist anders aufgebaut: Abhängigkeiten und Assets werden in separaten Stages gebaut, ins finale Image gelangt nur, was für den Betrieb nötig ist, der Prozess läuft nicht als root, und die Konfiguration kommt beim Start aus der Umgebung. Dieser Artikel zeigt ein vollständiges Dockerfile mit einer Begründung jeder Entscheidung sowie ein Schema, um Web, Worker und Scheduler aus einem einzigen Image zu starten.

**Navigation:** [Alle Tools](../) · [Sail: Überblick](sail) · [Sail: Umgebung und Deployment](sail-env-deploy) · [Sail: Queues](sail-queues) · [Zero-Downtime-Deployment](../architecture/zero-downtime-deployment-laravel)

## Inhalt

* [Warum das Sail-Image nicht für die Produktion taugt](#sail-vs-prod)
* [Architektur: ein Image – mehrere Prozesse](#image-layout)
* [.dockerignore: was nicht in den Kontext gehört](#dockerignore)
* [Das vollständige Multi-Stage-Dockerfile](#multistage)
* [Layer-Reihenfolge und Build-Cache](#layer-cache)
* [PHP-FPM und OPcache für die Produktion](#php-config)
* [Konfiguration und Secrets](#config)
* [Web, Worker und Scheduler aus einem Image](#processes)
* [Healthcheck und sauberes Herunterfahren](#healthchecks)
* [Sicherheit des Images](#security)
* [Alpine, Debian oder FrankenPHP](#base-image)
* [Häufige Fehler](#common-mistakes)
* [Checkliste](#checklist)
* [Quiz zur Selbstkontrolle](#self-test-quiz)

---

<a id="sail-vs-prod"></a>
## Warum das Sail-Image nicht für die Produktion taugt

Sail ist ein hervorragendes Entwicklungswerkzeug – und gerade deshalb ungeeignet für eine produktive Umgebung:

* Die Anwendung läuft über `php artisan serve` unter Supervisor – das ist der eingebaute PHP-Entwicklungsserver, nicht PHP-FPM;
* das Image enthält Xdebug, Node.js, npm, MySQL-/PostgreSQL-Clients und weitere Tools – mehr Größe und mehr Angriffsfläche;
* der Code wird als Volume vom Host gemountet statt ins Image kopiert – das Image selbst enthält keine Anwendung;
* Berechtigungen und UID werden beim Containerstart an den Benutzer des Hosts angepasst.

Das Production-Image ist ein eigenes Artefakt mit eigenem Dockerfile. Sail bleibt dabei für die lokale Entwicklung erhalten.

---

<a id="image-layout"></a>
## Architektur: ein Image – mehrere Prozesse

Das Prinzip „ein Prozess pro Container“ bedeutet für Laravel: **ein Anwendungs-Image**, aus dem verschiedene Prozesse gestartet werden.

```
                 ┌──────────────────────┐
  HTTP ────────► │ web (nginx)          │ static files from public/
                 └──────────┬───────────┘
                            │ FastCGI :9000
                 ┌──────────▼───────────┐
                 │ app (php-fpm)        │ ┐
                 └──────────────────────┘ │
                 ┌──────────────────────┐ │  same image,
                 │ queue (queue:work)   │ ├─ different command
                 └──────────────────────┘ │
                 ┌──────────────────────┐ │
                 │ scheduler            │ ┘
                 │ (schedule:work)      │
                 └──────────────────────┘
```

So haben alle Prozesse garantiert denselben Code und dieselben Extensions und lassen sich trotzdem unabhängig skalieren: mehr Worker während eines Imports, mehr PHP-FPM zu Spitzenzeiten. nginx wird in einer eigenen kleinen Stage gebaut, in die nur `public/` kopiert wird.

---

<a id="dockerignore"></a>
## .dockerignore: was nicht in den Kontext gehört

Alles, was im Build-Verzeichnis liegt, wird als Kontext an den Docker-Daemon gesendet. Ohne `.dockerignore` landen per `COPY . .` `.git`, das lokale `vendor/`, `node_modules/` und – am gefährlichsten – die `.env` im Image.

```gitignore
# .dockerignore
.git
.env
.env.*
!.env.example
node_modules
vendor
public/build
public/hot
public/storage
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*.php
tests
docker-compose*.yml
*.log
```

`vendor/` und `public/build/` werden ausgeschlossen, weil sie innerhalb von Docker gebaut werden – mit der richtigen PHP-Version, ohne Dev-Pakete und reproduzierbar.

---

<a id="multistage"></a>
## Das vollständige Multi-Stage-Dockerfile

```dockerfile
# syntax=docker/dockerfile:1
ARG PHP_VERSION=8.5

# ---------- base: PHP runtime with the extensions the app needs ----------
FROM php:${PHP_VERSION}-fpm AS base

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_pgsql redis intl zip bcmath pcntl \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY docker/php/conf.d/ $PHP_INI_DIR/conf.d/
COPY docker/php-fpm/zz-app.conf /usr/local/etc/php-fpm.d/zz-app.conf

WORKDIR /var/www/html

# ---------- vendor: production Composer dependencies ----------
FROM base AS vendor

ENV COMPOSER_HOME=/tmp/composer \
    COMPOSER_CACHE_DIR=/tmp/composer/cache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/composer/cache \
    composer install --no-dev --no-scripts --no-autoloader \
        --prefer-dist --no-interaction --no-progress

# ---------- assets: Vite build ----------
FROM node:24-alpine AS assets
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund

COPY vite.config.* ./
COPY resources/ ./resources/
RUN npm run build

# ---------- runtime: the image that runs in production ----------
FROM base AS runtime

COPY --from=vendor /var/www/html/vendor ./vendor
COPY . .
COPY --from=assets /app/public/build ./public/build

# Composer is mounted only for this step and never ends up in the image.
RUN --mount=type=bind,from=composer:2,source=/usr/bin/composer,target=/usr/local/bin/composer \
    composer dump-autoload --optimize --no-dev --no-interaction \
 && mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views \
             storage/logs bootstrap/cache \
 && chown -R www-data:www-data storage bootstrap/cache

COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/entrypoint

USER www-data
EXPOSE 9000
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm"]

# ---------- web: nginx with public assets only ----------
FROM nginx:stable-alpine AS web
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=runtime /var/www/html/public /var/www/html/public
```

Zwei Images aus einer Datei bauen:

```bash
docker build --target runtime -t registry.example.com/shop:1.42.0 .
docker build --target web     -t registry.example.com/shop-web:1.42.0 .
```

Die Stages im Einzelnen:

* **`base`** – PHP mit Extensions. `install-php-extensions` installiert die Systembibliotheken selbst und entfernt die Compiler nach dem Bau der Extension. `php.ini-production` aktiviert die Produktionseinstellungen: Fehler werden nicht in der Antwort ausgegeben, `expose_php` ist ausgeschaltet. In PHP 8.5 ist OPcache immer Teil des Kerns; bei PHP 8.4 und älter ergänzen Sie `opcache` in der Liste der Extensions.
* **`vendor`** – nur `composer.json` und `composer.lock`, ohne Anwendungscode. `--no-scripts --no-autoloader` sind nötig, weil die Laravel-Skripte (`package:discover`) Code benötigen, den es in dieser Stage noch nicht gibt. Die Stage erbt von `base`, deshalb prüft Composer die Anforderungen an PHP-Version und Extensions – `--ignore-platform-reqs` ist unnötig und schädlich.
* **`assets`** – Node.js wird nur hier gebraucht. Scannt Tailwind Blade-Templates oder Pagination-Klassen aus `vendor/`, kopieren Sie diese ebenfalls in diese Stage.
* **`runtime`** – das finale Image: Code, `vendor/`, gebaute Assets. `composer dump-autoload --optimize` erzeugt die Classmap und führt `package:discover` aus. Composer wird über `--mount=type=bind` nur für die Dauer dieses Befehls eingebunden.
* **`web`** – nginx und der Inhalt von `public/`. PHP-Code enthält es nicht.

Die nginx-Konfiguration für dieses Schema:

```nginx
# docker/nginx/default.conf
server {
    listen 80;
    root /var/www/html/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}
```

---

<a id="layer-cache"></a>
## Layer-Reihenfolge und Build-Cache

Docker baut einen Layer neu, wenn sich seine Eingabedaten geändert haben, und dazu alle Layer danach. Daraus folgt die wichtigste Regel: **zuerst, was sich selten ändert, danach, was sich oft ändert**.

* `composer.json` und `composer.lock` werden getrennt vom Code kopiert – eine Änderung an einem Blade-Template startet `composer install` nicht neu.
* `package.json` und `package-lock.json` – genauso für npm.
* `COPY . .` steht ganz am Ende, nach allen schweren Schritten.

Die zweite Ebene sind **BuildKit-Cache-Mounts**: `RUN --mount=type=cache,target=...`. Selbst wenn der Layer mit den Abhängigkeiten neu gebaut wird (Sie haben ein Paket hinzugefügt), holen Composer und npm bereits heruntergeladene Archive aus dem Cache statt aus dem Netz. Der Cache landet nicht im Image.

In der CI wird der Cache zwischen Läufen über `docker buildx build --cache-to/--cache-from` gesichert (zum Beispiel in der Registry oder im Cache von GitHub Actions). Ohne das beginnt jeder Build auf einem frischen Runner bei null.

---

<a id="php-config"></a>
## PHP-FPM und OPcache für die Produktion

```ini
; docker/php/conf.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
; Code never changes inside a running container: skip file timestamp checks.
opcache.validate_timestamps=0
```

```ini
; docker/php/conf.d/app.ini
memory_limit=256M
upload_max_filesize=20M
post_max_size=25M
expose_php=Off
```

```ini
; docker/php-fpm/zz-app.conf
[www]
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 1000
ping.path = /ping
clear_env = no
```

* **`opcache.validate_timestamps=0`** – im Container ist der Code unveränderlich, deshalb muss PHP nicht bei jedem Request die Änderungszeit der Dateien prüfen. Neuer Code kommt nur mit einem neuen Container.
* **`pm.max_children`** wird aus dem Speicher berechnet: Stehen dem Container 1 GB zur Verfügung und belegt ein PHP-FPM-Worker etwa 50 MB, verkraftet er nicht mehr als 15–18 Prozesse. Messen Sie den tatsächlichen Verbrauch unter Last.
* **`pm.max_requests`** startet einen FPM-Prozess nach N Requests neu – eine Absicherung gegen schleichende Speicherlecks.
* **`clear_env = no`** – standardmäßig leert PHP-FPM die Umgebungsvariablen für seine Prozesse. Ist die Konfiguration nicht gecacht, sieht die Anwendung sie nicht.

Anwendungslogs gehören im Container nach stdout/stderr, nicht in eine Datei innerhalb des Containers: `LOG_CHANNEL=stderr`. Dann sammelt sie Docker oder der Orchestrator ein, und sie gehen beim Neuerstellen des Containers nicht verloren.

---

<a id="config"></a>
## Konfiguration und Secrets

**Im Image gibt es keine `.env`.** Ein und dasselbe Image muss auf Staging und in Produktion laufen – der Unterschied liegt nur in den Umgebungsvariablen, die dem Container übergeben werden. Ein Image mit einer `.env` ist an eine einzige Umgebung gebunden und verteilt Secrets an jeden, der Zugriff auf die Registry hat.

**Laravel-Caches werden beim Containerstart gebaut, nicht beim Build.** `config:cache` brennt die aktuellen Werte der Umgebungsvariablen in `bootstrap/cache/config.php` ein. Führt man es im Dockerfile aus, landen die Werte der Build-Umgebung im Image. Bei Routen ist es dieselbe Geschichte, wenn sie von der Konfiguration abhängen. In DevSense wird zum Beispiel die Route für den IndexNow-Schlüssel nur registriert, wenn `config('seo.indexnow_key')` gesetzt ist – ein `route:cache` während des Builds würde den Routensatz ohne sie festschreiben.

```sh
#!/bin/sh
# docker/entrypoint.sh
set -e

# Build config, route, view and event caches from the runtime environment.
if [ "${LARAVEL_OPTIMIZE:-true}" = "true" ]; then
    php artisan optimize
fi

# exec replaces the shell, so PHP becomes PID 1 and receives SIGTERM directly.
exec "$@"
```

**Migrationen sind ein eigener Release-Schritt, nicht Teil des Entrypoints.** Läuft `migrate --force` beim Start jedes Containers, starten zehn Replikas die Migrationen gleichzeitig. Führen Sie sie einmal vor dem Umschalten des Traffics aus: `docker compose run --rm app php artisan migrate --force` oder als eigenen Job im Orchestrator. Wie man Migrationen schreibt, die mit dem laufenden Code kompatibel sind, steht im Artikel über [Deployment ohne Downtime](../architecture/zero-downtime-deployment-laravel#migrations).

**Secrets zur Build-Zeit über `--mount=type=secret`.** Braucht `composer install` ein Token für ein privates Repository, übergeben Sie es nicht per `ARG` oder `ENV`: Die Werte sind in der Image-Historie sichtbar (`docker history`). BuildKit mountet das Secret nur für die Dauer eines einzigen Befehls:

```dockerfile
RUN --mount=type=cache,target=/tmp/composer/cache \
    --mount=type=secret,id=composer_auth,target=/tmp/composer/auth.json \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
```

```bash
docker build --secret id=composer_auth,src=$HOME/.config/composer/auth.json --target runtime .
```

---

<a id="processes"></a>
## Web, Worker und Scheduler aus einem Image

```yaml
# docker-compose.prod.yml
services:
  app:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    init: true
    restart: unless-stopped

  web:
    image: registry.example.com/shop-web:${RELEASE}
    ports: ["80:80"]
    depends_on: [app]
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/up"]
      interval: 10s
      timeout: 3s
      retries: 3

  queue:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    command: ["php", "artisan", "queue:work", "--tries=3", "--timeout=120", "--max-time=3600", "--memory=256"]
    init: true
    stop_grace_period: 180s
    restart: unless-stopped

  scheduler:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    command: ["php", "artisan", "schedule:work"]
    init: true
    restart: unless-stopped
```

* `command` überschreibt `CMD`, während der `ENTRYPOINT` mit `optimize` bei allen Prozessen greift.
* `restart: unless-stopped` ersetzt Supervisor: Ein `queue:work`, der sich wegen `--max-time` oder nach `queue:restart` beendet hat, wird neu gestartet.
* `init: true` startet einen kleinen Init-Prozess, der Signale weiterleitet und „Zombie“-Prozesse einsammelt.
* `schedule:work` ist der Scheduler ohne Cron: Der Befehl startet jede Minute `schedule:run`. In Kubernetes verwendet man stattdessen oft einen CronJob mit `schedule:run`.

Die Parameter der Worker – Timeouts, Retries, Speicher – werden ausführlich im Artikel über [Queues in Produktion](../architecture/laravel-queues-production) behandelt.

---

<a id="healthchecks"></a>
## Healthcheck und sauberes Herunterfahren

**Health-Check.** Laravel 11+ hat die eingebaute Route `/up` (Parameter `health` in `bootstrap/app.php`): Sie antwortet mit `200`, wenn die Anwendung hochgefahren ist, und mit `500`, wenn nicht. Ein Check über nginx (`wget` ist im Alpine-Image vorhanden) prüft die gesamte Kette nginx → PHP-FPM → Laravel. Für einen separaten Check von PHP-FPM gibt es `ping.path = /ping` aus der Pool-Konfiguration; abfragen lässt er sich mit dem Tool `cgi-fcgi` aus dem Paket fcgi. Kubernetes ignoriert die Anweisung `HEALTHCHECK` aus dem Dockerfile und verwendet eigene Readiness- und Liveness-Probes – dieselben URLs eignen sich auch dafür.

**Sauberes Herunterfahren.** `docker stop` sendet dem Hauptprozess `SIGTERM` und nach `stop_grace_period` (standardmäßig 10 Sekunden) `SIGKILL`. Damit das Herunterfahren sauber verläuft:

* Der Hauptprozess ist PHP selbst, nicht die Shell: Im Entrypoint steht `exec "$@"`, und `CMD` ist in der Exec-Form geschrieben (`["php-fpm"]` statt `php-fpm` als String);
* für den Queue-Worker ist die Extension **pcntl** installiert – `queue:work` empfängt `SIGTERM`, beendet den aktuellen Job und steigt aus;
* `stop_grace_period` ist länger als der längste Job – sonst beendet Docker den Worker mitten in der Arbeit. Das ist dasselbe Prinzip wie `stopwaitsecs` in Supervisor.

---

<a id="security"></a>
## Sicherheit des Images

* **Nicht als root.** `USER www-data` am Ende des Dockerfiles. Wird in der Anwendung eine RCE gefunden, erhält der Angreifer die Rechte von `www-data` im Container und nicht root. PHP-FPM lauscht auf Port 9000, dafür sind keine Privilegien nötig. Die FPM-Warnung zur Direktive `user`, die ohne root ignoriert wird, ist unbedenklich.
* **Minimum an Software.** Kein Xdebug, Node.js, Composer, git und keine Compiler im finalen Image. Was nicht da ist, kann nicht ausgenutzt werden.
* **Fixierte Versionen.** `php:8.5-fpm` ändert sich mit jedem Patch-Release. Für Reproduzierbarkeit pinnen Sie den Digest: `FROM php:8.5-fpm@sha256:...`, und automatisieren Sie Updates der Basis-Images mit Dependabot oder Renovate.
* **Regelmäßig neu bauen.** Schwachstellen in OpenSSL oder glibc werden durch ein neues Basis-Image geschlossen – auch wenn sich Ihr Code nicht geändert hat, muss das Image mindestens alle ein bis zwei Wochen neu gebaut werden.
* **Scannen.** `docker scout cves` oder `trivy image` in der CI finden bekannte Schwachstellen in Systempaketen und Abhängigkeiten. Für die PHP-Abhängigkeiten bleiben `composer audit` und `npm audit`.
* **Nur lesend.** Auf der nächsten Stufe wird das Root-Dateisystem des Containers mit `read_only: true` gemountet, und beschreibbar bleiben nur `storage/`, `bootstrap/cache` und `/tmp`.

Die Servereinstellungen rund um die Container – Security-Header, TLS, Limits – beschreibt der Artikel über [Hardening von Servern und Infrastruktur](../security/server-and-infrastructure-hardening).

---

<a id="base-image"></a>
## Alpine, Debian oder FrankenPHP

| Variante | Vorteile | Nachteile |
|---------|-------|--------|
| `php:8.x-fpm` (Debian) | Berechenbares glibc, schnelle fertige Pakete, weniger Überraschungen | Größeres Image |
| `php:8.x-fpm-alpine` | Geringe Größe | musl statt glibc: längerer Build der Extensions, Abweichungen im Verhalten einzelner Bibliotheken und in der Performance |
| FrankenPHP | Ein Prozess: Caddy-Webserver und PHP zusammen, HTTPS, Worker-Modus für Laravel Octane | Anderes Ausführungsmodell: Im Worker-Modus lebt der Zustand zwischen Requests weiter, Speicherlecks und „schmutzige“ Singletons werden zu Ihrem Problem |

Für ein typisches Laravel-Projekt ist ein Debian-Image mit PHP-FPM und nginx, wie in diesem Artikel, ein vernünftiger Start. Alpine ergibt Sinn, wenn die Größe wirklich wichtig ist und Sie die Anwendung unter musl getestet haben. Die PHP-Ausführungsmodelle – FPM, Worker, Event Loop – werden im Artikel [PHP auf dem Server](../php/runtimes) verglichen.

---

<a id="common-mistakes"></a>
## Häufige Fehler

**1. Sail-Image oder Dev-Image in Produktion.**
`artisan serve`, Xdebug und Node.js in der produktiven Umgebung: langsam und unsicher.

**2. `.env` im Image.**
Secrets in jedem Layer und bei jedem, der das Image herunterladen kann. Konfiguration nur über die Umgebung.

**3. `config:cache` im Dockerfile.**
Ins Image werden die Variablen der Build-Umgebung eingebrannt, die Produktionseinstellungen werden ignoriert.

**4. `COPY . .` vor `composer install`.**
Jede Codeänderung invalidiert den Cache der Abhängigkeiten, und jeder Build lädt alle Pakete neu herunter.

**5. `--ignore-platform-reqs` in Composer.**
Der Build läuft durch, aber zur Laufzeit fehlt eine PHP-Extension. Installieren Sie die Abhängigkeiten mit demselben PHP wie in der Produktion.

**6. Secrets über `ARG` oder `ENV`.**
Die Werte sind in `docker history` sichtbar. Verwenden Sie `--mount=type=secret`.

**7. Shell-Form von `CMD` und Entrypoint ohne `exec`.**
`SIGTERM` empfängt die Shell, nicht PHP. Der Worker fährt nicht sauber herunter und wird nach 10 Sekunden mitten im Job beendet.

**8. `migrate --force` im Entrypoint jeder Replika.**
Mehrere Container starten die Migrationen gleichzeitig. Migrationen sind ein eigener Release-Schritt.

**9. Prozess als root.**
Jede Schwachstelle in der Anwendung verschafft sofort root-Rechte im Container.

---

<a id="checklist"></a>
## Checkliste

1. Es gibt eine `.dockerignore`: `.env`, `.git`, `vendor/`, `node_modules/` gelangen nicht in den Kontext.
2. Multi-Stage-Build: Composer und Node.js nur in den Build-Stages.
3. `composer.json`/`composer.lock` und `package*.json` werden vor dem Code kopiert; BuildKit-Cache-Mounts werden genutzt.
4. Abhängigkeiten werden mit demselben PHP und denselben Extensions wie zur Laufzeit installiert, ohne `--ignore-platform-reqs`.
5. `php.ini-production`, OPcache mit `validate_timestamps=0`, `pm.max_children` aus dem Speicher berechnet.
6. Im Image gibt es keine `.env` und keine Secrets; private Tokens über `--mount=type=secret`.
7. `php artisan optimize` läuft beim Containerstart; Migrationen als eigener Release-Schritt.
8. Der Prozess läuft als `www-data`; das Basis-Image ist per Digest fixiert und wird regelmäßig neu gebaut.
9. web, queue und scheduler werden aus einem Image mit unterschiedlichen Befehlen gestartet.
10. Es gibt einen Healthcheck über `/up`, `exec` im Entrypoint, pcntl und eine `stop_grace_period`, die länger ist als der längste Job.
11. Das Image wird in der CI auf Schwachstellen gescannt.

---

## Zusammenfassung

Ein Production-Image für Laravel ist nicht „Sail plus COPY“, sondern ein eigenes Artefakt: in mehreren Stages gebaut, enthält es nur, was für den Betrieb nötig ist, weiß nichts über eine konkrete Umgebung und läuft ohne root. Layer-Reihenfolge und BuildKit-Cache machen den Build schnell, `optimize` beim Start übernimmt die echte Konfiguration, und ein einziges Image für Web, Worker und Scheduler garantiert, dass alle Prozesse mit demselben Code laufen.

---

<a id="self-test-quiz"></a>
## Selbsttest-Quiz

### Frage 1: Warum werden `composer.json` und `composer.lock` separat und vor dem restlichen Code ins Image kopiert?
- A) Composer kann keine Dateien lesen, die mit `COPY . .` kopiert wurden.
- B) Damit der Layer mit den installierten Abhängigkeiten aus dem Cache wiederverwendet wird, solange sich die Manifeste selbst nicht ändern, und Codeänderungen `composer install` nicht neu anstoßen.
- C) Das verlangt das Format eines Multi-Stage-Builds.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Docker baut einen Layer neu, wenn sich seine Eingabedaten ändern. Werden die Abhängigkeiten nach `COPY . .` installiert, invalidiert jede Template-Änderung den Layer mit `vendor/`. Das separate Kopieren der Manifeste entkoppelt die Installation der Abhängigkeiten von Codeänderungen.
</details>

### Frage 2: Der Befehl `php artisan config:cache` wird im Dockerfile ausgeführt. Was passiert in Produktion?
- A) Nichts Besonderes: Laravel liest die Umgebungsvariablen beim Start neu ein.
- B) Die Anwendung verwendet die Werte, die während des Builds in der Umgebung standen, und ignoriert die Variablen, die dem Container übergeben werden.
- C) Der Build schlägt fehl, weil `config:cache` ohne Datenbank nicht ausgeführt werden kann.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Eine gecachte Konfiguration liest `env()` nicht erneut. Der Cache muss beim Containerstart gebaut werden, wenn die Umgebungsvariablen bereits übergeben sind – zum Beispiel mit `php artisan optimize` im Entrypoint.
</details>

### Frage 3: Bei `docker stop` wird der Queue-Worker mitten in einem Job abgebrochen. Was davon hilft NICHT?
- A) `exec "$@"` im Entrypoint und die Exec-Form von `CMD`, damit PHP das `SIGTERM` empfängt.
- B) Die installierte Extension pcntl und eine `stop_grace_period`, die länger ist als der längste Job.
- C) `memory_limit` in der `php.ini` erhöhen.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: C**
Sauberes Herunterfahren hängt davon ab, dass das Signal beim PHP-Prozess ankommt, `SIGTERM` verarbeitet wird (pcntl) und Docker genug Zeit bis zum `SIGKILL` lässt. Das Speicherlimit hat damit nichts zu tun.
</details>