Deployment mit Docker Compose

Deployment mit Docker Compose

Um das Backend API zu auf einem Server zu deployen, muss ein Dockerfile im Projekt Root Verzeichnis erstellt werden. Das Dockerfile wird für einen Multistage Build vorbereitet.
Dies soll das Image klein halten und den build beschleunigen. Dazu muss auch ein entsprechendes .dockerignore File vorhanden sein. Um die Applikation auf den Zielserver zu bringen, wird Docker-Hub verwendet. Als Webserver kommt Nginx zum Einsatz welcher am Zielserver mittels Docker-Compose konfiguriert und gestartet wird. Da es sich beim Server um ein Raspberry Pi im lokalen Netz handelt, wird Cloudflare mit Zero Trust eingesetzt um den Server im Internet erreichbar zu machen. Als Webserver und Reverse Proxy wird Traefik verwendet. Dieser leitet die Anfragen intern, an die entsprechenden Services weiter.

Das Dockerfile

bash
# ────── Build Stage ──────

FROM php:8.3-fpm-alpine AS builder

# Temporäre Build-Tools installieren
RUN apk add --no-cache --virtual .build-deps \
libzip-dev \
postgresql-dev \
unzip \
git \
bash \
&& docker-php-ext-install pdo pdo_pgsql

# Composer installieren
COPY --from=composer:2.8.8 /usr/bin/composer /usr/bin/composer

# Arbeitsverzeichnis
WORKDIR /app

  

# Nur Composer-Dateien zuerst (für Caching)
COPY composer.json composer.lock ./

# Production-Abhängigkeiten installieren
RUN composer install --no-dev --optimize-autoloader

# App-Code kopieren
COPY . .


# ────── Runtime Stage ──────

FROM php:8.3-fpm-alpine AS runner

# Nur Runtime-Dependencies
RUN apk add --no-cache \
libzip \
libpq

# PHP mit Extensions aus builder übernehmen
COPY --from=builder /usr/local/lib/php /usr/local/lib/php
COPY --from=builder /usr/local/etc/php /usr/local/etc/php
COPY --from=builder /usr/local/bin/php* /usr/local/bin/

# App-Code übernehmen
WORKDIR /var/www/html
COPY --from=builder /app /var/www/html

# Composer im Runner entfernen (nicht nötig zur Laufzeit)
RUN rm -f /usr/bin/composer

# Rechte setzen
RUN chown -R www-data:www-data /var/www/html

EXPOSE 9000

CMD ["php-fpm"]

Im Dockerfile gibt es 2 stages, den builder stage und den runner stage. Im builder stage werden alle Abhängigkeiten für PHP und Composer sowie der PHP Packagemanager “Composer” installiert. Danach werden alle Abhängigkeiten für die Applikation aus dem .composer-lock in das definierte WORKDIR installiert. Zuletzt wird noch der Applikationscode selbst in das WORKDIR kopiert. Diese Reihenfolge ist wichtig da Docker mit jedem Befehl einen neuen Layer zum Basis-Image hinzufügt. Docker kann diese Layer wiederverwenden was einem erneuten Build erheblich schneller macht. Diese Layer sind aber auch unveränderbar, das bedeutet wenn es in einem bestimmten Layer eine Änderung gibt, muss dieser Layer sowie alle nachfolgenden neu gebaut werden. Somit sollte der Applikationscode der sich vermutlich am häufigsten ändert, am Schluss eingefügt werden.

Im runner stage wird ein kleineres Basis-Image verwendet. Danach werden wieder alle PHP Abhängigkeiten installiert. Es wird das korrekte WORKDIR für den Webserver auf dem die Applikation läuft angegeben. Danach wird die fertige Applikation vom builder WORKDIR in das des runner kopiert. Im nächsten Schritt werden noch die Berechtigungen vergeben sowie der Port auf dem PHP-Prozessmanager lauscht. Zum Schluss wird der Startbefehl mitgegeben mit welchen die Applikation gestartet wird. Es wird FPM verwendet - FastCGI Process Manager ist ein Prozessmanager der die Anfragen des Webservers entgegennimmt. Oder in diesem Fall, nimmt Traefik die Anfragen entgegen und leitet diese weiter an Nginx, welcher die Anfrage an den FPM weiterleitet.

Das .dockerignore File stellt sicher dass nur die relevanten Daten ins Image kopiert werden.

bash
#docker ignore file for bookmarkapi slim php project
.git
node_modules/
vendor/
.env
.env.*
docker-compose.yml
docker-compose.*
Dockerfile
Dockerfile.*
.cache

Das Docker Image

Um die Applikation auszuliefern muss aus dem Dockerfile ein Image erstellt werden. Dies wird im Root-Verzeichnis der Applikation (in welchen das Dockerfile) liegt mit folgendem Befehl gestartet. Der Punkt bedeutet, builde das Image aus dem aktuellen Verzeichnis.

bash
docker build .

Das Image soll aber in Docker-Hub gepusht werden um es am Zielserver im Docker-Compose File wieder herunterladen zu können. Hier wird zusätzlich der Docker-Hub User/Applikationsname mit einem Tag angegeben. Dieser Befehl funktioniert aber nur für amd64 CPU-Architekturen. (Intel, AMD)

bash
docker build -t lauhard/bookmarkapi:latest .

Da das Zielsystem eine linux/arm64 Architektur hat, wird es etwas komplizierter. Der Docker default builder kann standardmäßig keine builds für andere CPU Architekturen. Somit muss ein neuer Builder erzeugt und gestartet werden. Die folgenden zwei Befehle erzeugen und startet einen neuen Builder namens armbuilder Der letzte Befehl gibt den Status sowie die Plattformen des Builders aus.

bash
docker buildx create --name armbuilder
docker buildx use armbuilder
docker buildx inspect --bootstrap

Nun kann das Docker Image für den Arm-Server gebuildet werden.

bash
docker buildx build \
--platform linux/arm64 \
-t lauhard/bookmarkapi:latest-arm \
--push \ .

Das Docker-Compose File

Das docker-compose.yml File konfiguriert und verwaltet Docker Services, Docker Network sowie Docker Storage.

bash
services:
	bookmarkapi:
		image: lauhard/bookmarkapi:1.0.2-arm64
		platform: linux/arm64
		container_name: bookmarkapi
		restart: unless-stopped
		environment:
			POSTGRES_DB_PASSWORD_FILE: /run/secrets/postgres_db_password
		env_file:
			- .env
		expose:
			- "9000"
		volumes:
			- app-shared:/var/www/html # ✅ App-Code ins Volume schreiben
			- ./secrets/postgres_db_password.txt:/run/secrets/postgres_db_password:ro
		networks:
			- backend
			- frontend
		healthcheck:
			test: ["CMD", "curl", "-f", "http://localhost:9000"]
			interval: 10s
			timeout: 5s
			retries: 3


	bookmarkapi-nginx:
		image: nginx:alpine
		container_name: bookmarkapi-nginx
		restart: unless-stopped
		depends_on:
			bookmarkapi:
			condition: service_healthy
		volumes:
			- ./services/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro #ro =read only
			- app-shared:/var/www/html:ro # ✅ App-Code lesen
		
		networks:
			- frontend
			- backend
		labels:
			- "traefik.enable=true"
			- "traefik.http.routers.bookmarkapi.rule=Host(`bookmarkapi.alau.at`)"
			- "traefik.http.routers.bookmarkapi.entrypoints=websecure"
			- "traefik.http.routers.bookmarkapi.tls=true"
			- "traefik.http.services.bookmarkapi.loadbalancer.server.port=80"
			- "traefik.http.routers.bookmarkapi.tls.domains[0].main=alau.at"
			- "traefik.http.routers.bookmarkapi.tls.domains[0].sans=*.alau.at"
			
			# Middleware mit Sicherheitsheadern
			- "traefik.http.middlewares.hsts.headers.stsSeconds=63072000"
			- "traefik.http.middlewares.hsts.headers.stsIncludeSubdomains=true"
			- "traefik.http.middlewares.hsts.headers.stsPreload=true"
			- "traefik.http.middlewares.hsts.headers.referrerPolicy=strict-origin-when-cross-origin"
			- "traefik.http.middlewares.hsts.headers.contentTypeNosniff=true"
			- "traefik.http.middlewares.hsts.headers.browserXssFilter=true"
			- "traefik.http.middlewares.hsts.headers.frameDeny=true"
			- "traefik.http.routers.bookmarkapi.middlewares=hsts"

volumes:
	app-shared:

networks:
	frontend:
		external: true
	backend:
		external: true

Im Service bookmarkapi wird das Image bookmarkapi:latest-arm von hub.docker.com geladen. https://hub.docker.com/repository/docker/lauhard/bookmarkapi/general Dazu muss noch die verwendete Plattform angegeben werden. Der Container erhält den Namen bookmarkapi und kann im Netzwerk direkt mit diesem Namen angesprochen werden. Als restart Befehl wird unless-stopped gesetzt. Das heißt der Service soll automatisch erneut starten außer er wird manuell beendet. Danach werden die benötigten Environment-Variablen aus dem .env File in den Container übergeben. Mit dem Befehl expose wird der Port 9000 in den Docker-Netzwerken frontend und backend freigegeben. Im unterschied zum ports Befehl wird der Port nicht nach außen (zum Hostsystem) freigegeben. In Volumes wird das shared-Volume app-shared definiert, welches den API-Code enthält. Dieses Volume wird zwischen dem PHP-FPM-Container (bookmarkapi) und dem Webserver (bookmarkapi-nginx) geteilt. Ausserdem wird der Pfad für das secrets-File mit dem Postgres Datenbank Passwort gesetzt. Danach wird der Service noch für die Netzwerke -backend sowie -frontend verfügbar gemacht. Zum Schluss wird noch ein Health-Check gemacht, welcher sicherstellt dass der Service läuft und erreichbar ist.

Der Webserer

Der zweite Service bookmarkapi-nginx erzeugt einen Nginx Server, welcher nur als Gateway dient.

bash
server {
	listen 80; 
	server_name bookmarkapi.alau.at;
	root /var/www/html/public;

	index index.php index.html index.htm;

	access_log /var/log/nginx/bookmarkapi.access.log;
	error_log /var/log/nginx/bookmarkapi.error.log warn;

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

	location ~ \.php$ {
		include fastcgi_params;
		fastcgi_pass bookmarkapi:9000;
		fastcgi_index index.php;
		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
		fastcgi_param PATH_INFO $fastcgi_path_info;
		fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
	}

	location ~ /\.ht {
		deny all;
	}
}

Mit dem Befehl depends_on wird die Reihenfolge beim Starten der Container berücksichtigt. Mit dem Befehl condition wird mit dem Starten des Servers gewartet, bis vom Health-Check aus dem bookmarkapi Service ein service_healthy zurück kommt. In volumes wird der Pfad zum Nginx Config sowie der Pfad zum shared-Volume angegeben welches Nginx braucht um die Daten an den FastCGI Prozess Manager weiterzugeben.

Der Fast CGI Process Manager ist ein PHP Interpreter, also ein Hintergrundprozess, der PHP-Scripte ausführt. Dieser Prozess Manager versteht nur FastCGI was ein binäres Protokoll ist. Das heißt, PHP läuft nur als Prozess Manager. Deshalb wird ein Webserver wie Nginx oder Apache benötigt, der den HTTP-Request an FastCGI weiterreicht.

Dies passiert im folgenden Config-File von Nginx Nginx lauscht auf dem Port 80 und nimmt nur Anfragen von der Domain bookmarkapi.alau.at entgegen. Danach wird das Root Verzeichnis angegeben. Im ersten location-Block wird die URI überprüft ob im public-Folder bestimmte Dateien und Verzeichnisse liegen, zum Beispiel statische Dateien die direkt zurückgegeben werden können. Wird nichts passendes gefunden, wird index.php mit Query-Parametern weitergegen.

Der zweite location-Block gilt für alle PHP-Files. Da der Webserver selbt, keine PHP-Datei ausführen kann, wird das PHP-File an FastCGI weitergereicht. Es werden die FastCGI Parameter geladen, danach kommt die Weiterleitung an den Service bookmarkapi im Container auf Port 9000. Mit SCRIPT_FILENAME wird genau mitgeteilt, in welchem Verzeichnis welches PHP-File ausgeführt werden soll.