From f4aece955788f17cb9f06b015e4faa47045b0ebe Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:40 +0200 Subject: [PATCH] build: Docker-Setup und Apache-Konfiguration Apache mit mod_php statt PHP-FPM plus Nginx: dadurch verhalten sich Docker und klassisches Shared Hosting gleich, es gilt dieselbe .htaccess. MariaDB liegt hinter einem Compose-Profil. Die Entwicklungspakete bleiben im Image installiert - beim Aufraeumen mit apt-get purge --auto-remove verschwindet sonst auch die von imagick benoetigte Laufzeitbibliothek. Die .htaccess im Stammverzeichnis wird nur gebraucht, wenn der Webroot nicht auf public/ gelegt werden kann; sie sperrt dann alle uebrigen Verzeichnisse. --- .htaccess | 40 +++++++++++++++++++++++ Dockerfile | 75 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 78 ++++++++++++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 33 +++++++++++++++++++ docker/php.ini | 32 ++++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 .htaccess create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 docker/php.ini diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..b657c57 --- /dev/null +++ b/.htaccess @@ -0,0 +1,40 @@ +# ============================================================= +# urlshort-qr-gen – .htaccess fuer das Projekt-Stammverzeichnis +# +# Diese Datei wird NUR gebraucht, wenn beim Hoster der Webroot +# nicht auf /public gelegt werden kann (Shared-Hosting-Variante B +# in der README). Dann liegt das komplette Projekt im Webroot und +# diese Regeln leiten intern nach public/ weiter – gleichzeitig +# werden alle uebrigen Ordner gesperrt. +# +# Liegt der Webroot korrekt auf /public, ist diese Datei +# wirkungslos (sie liegt dann ausserhalb des Webroots). +# ============================================================= + +Options -Indexes +AddDefaultCharset UTF-8 + + + RewriteEngine On + + # 1) Nicht-oeffentliche Verzeichnisse hart sperren. + RewriteRule ^(src|templates|migrations|tests|docker|data|vendor|\.git)(/|$) - [F,L] + + # 2) Konfigurations- und Projektdateien sperren. + RewriteRule ^(\.env|composer\.(json|lock)|phpunit\.xml|Dockerfile|docker-compose\.ya?ml|.*\.md)$ - [F,L] + + # 3) Bereits nach public/ aufgeloeste Anfragen nicht erneut umschreiben. + RewriteCond %{REQUEST_URI} !^/?public/ + RewriteRule ^(.*)$ public/$1 [L] + + +# Zusaetzlicher Schutz, falls mod_rewrite fehlt. + + + Require all denied + + + Order allow,deny + Deny from all + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cadb5e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# ============================================================= +# urlshort-qr-gen – Apache + mod_php +# Bewusst dasselbe Setup wie klassisches Shared Hosting, damit +# sich Docker und FTP-Deployment identisch verhalten (.htaccess). +# ============================================================= + +FROM php:8.3-apache + +# --- Systempakete & PHP-Erweiterungen ------------------------- +# gd -> PNG-Ausgabe der QR-Codes +# intl -> Umlaut-Domains (IDN) nach UTS-46; optional, es gibt einen +# eigenen Punycode-Rueckfallweg fuer Hosting ohne intl +# pdo_mysql -> optionaler MySQL/MariaDB-Betrieb (SQLite ist eingebaut) +# imagick -> rastert SVG-Logos fuer die PNG-Ausgabe (optional) +# +# Die Entwicklungspakete bleiben absichtlich installiert: beim Aufraeumen +# mit "apt-get purge --auto-remove" verschwindet sonst auch die von +# imagick benoetigte Laufzeitbibliothek libMagickWand. Das Image ist +# dadurch groesser, aber die Erweiterungen laden zuverlaessig. +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + libpng-dev libjpeg62-turbo-dev libfreetype6-dev \ + libzip-dev libicu-dev libmagickwand-dev unzip; \ + docker-php-ext-configure gd --with-freetype --with-jpeg; \ + docker-php-ext-install -j"$(nproc)" gd intl pdo_mysql zip; \ + { pecl install imagick && docker-php-ext-enable imagick; } || \ + echo "Hinweis: imagick konnte nicht installiert werden – SVG-Logos wirken dann nur im SVG-Download."; \ + rm -rf /var/lib/apt/lists/* /tmp/pear; \ + php -m | grep -q '^gd$'; \ + php -m | grep -q '^pdo_mysql$' + +# --- Apache --------------------------------------------------- +RUN set -eux; \ + a2enmod rewrite headers expires; \ + sed -ri 's!/var/www/html!/var/www/html/public!g' /etc/apache2/sites-available/000-default.conf; \ + printf '%s\n' \ + '' \ + ' Options -Indexes +FollowSymLinks' \ + ' AllowOverride All' \ + ' Require all granted' \ + '' \ + 'ServerTokens Prod' \ + 'ServerSignature Off' \ + > /etc/apache2/conf-available/urlshort.conf; \ + a2enconf urlshort + +COPY docker/php.ini /usr/local/etc/php/conf.d/urlshort.ini + +# --- Composer ------------------------------------------------- +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html + +# Abhaengigkeiten zuerst: bleibt beim Aendern des Codes im Cache. +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-interaction --no-progress --optimize-autoloader --no-scripts + +COPY . . + +RUN set -eux; \ + mkdir -p data/uploads; \ + chown -R www-data:www-data data; \ + chmod -R 775 data + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD php -r 'exit(@file_get_contents("http://127.0.0.1/login") === false ? 1 : 0);' + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["apache2-foreground"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd1c225 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +# ============================================================= +# urlshort-qr-gen – Docker Compose +# +# Schnellstart (SQLite, nichts weiter noetig): +# docker compose up -d --build +# -> http://localhost:8080 +# +# Mit MariaDB statt SQLite: +# docker compose --profile mysql up -d --build +# (vorher in der .env DB_DRIVER=mysql und DB_HOST=db setzen) +# ============================================================= + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: urlshort-qr-gen:latest + container_name: urlshort-app + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:80" + environment: + # Diese Werte haben Vorrang vor Defaults, aber NICHT vor einer + # gemounteten .env-Datei. Fuer Docker daher entweder hier + # konfigurieren oder eine .env mitliefern - nicht beides. + APP_BASE_URL: "${APP_BASE_URL:-http://localhost:8080}" + APP_ENV: "${APP_ENV:-production}" + APP_TIMEZONE: "${APP_TIMEZONE:-Europe/Berlin}" + REDIRECT_STATUS: "${REDIRECT_STATUS:-302}" + + DB_DRIVER: "${DB_DRIVER:-sqlite}" + DB_SQLITE_PATH: "${DB_SQLITE_PATH:-data/database.sqlite}" + DB_HOST: "${DB_HOST:-db}" + DB_PORT: "${DB_PORT:-3306}" + DB_NAME: "${DB_NAME:-urlshort}" + DB_USER: "${DB_USER:-urlshort}" + DB_PASSWORD: "${DB_PASSWORD:-urlshort}" + + # Leer lassen -> beim ersten Aufruf startet der Setup-Assistent. + ADMIN_USER: "${ADMIN_USER:-admin}" + ADMIN_PASSWORD_HASH: "${ADMIN_PASSWORD_HASH:-}" + + SESSION_SECURE: "${SESSION_SECURE:-false}" + TRACK_IP: "${TRACK_IP:-none}" + IP_HASH_SALT: "${IP_HASH_SALT:-}" + VISIT_RETENTION_DAYS: "${VISIT_RETENTION_DAYS:-365}" + volumes: + # Datenbank und hochgeladene Logos ueberleben Neubauten. + - app-data:/var/www/html/data + healthcheck: + test: ["CMD", "php", "-r", "exit(@file_get_contents('http://127.0.0.1/login') === false ? 1 : 0);"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + db: + image: mariadb:11 + container_name: urlshort-db + restart: unless-stopped + profiles: ["mysql"] + environment: + MARIADB_DATABASE: "${DB_NAME:-urlshort}" + MARIADB_USER: "${DB_USER:-urlshort}" + MARIADB_PASSWORD: "${DB_PASSWORD:-urlshort}" + MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root-geheim}" + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + app-data: + db-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..6c6c8ec --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# ------------------------------------------------------------- +# Startskript des Containers. +# Sorgt dafuer, dass die Datenverzeichnisse existieren und dem +# Webserver gehoeren - auch wenn ein Volume gemountet wurde. +# ------------------------------------------------------------- +set -e + +mkdir -p /var/www/html/data/uploads + +if [ "$(id -u)" = "0" ]; then + chown -R www-data:www-data /var/www/html/data 2>/dev/null || true +fi + +# Wartet kurz auf die Datenbank, falls MySQL/MariaDB genutzt wird. +if [ "${DB_DRIVER}" = "mysql" ] && [ -n "${DB_HOST}" ]; then + echo "Warte auf Datenbank ${DB_HOST}:${DB_PORT:-3306} ..." + i=0 + while [ "$i" -lt 30 ]; do + if php -r ' + $host = getenv("DB_HOST"); $port = (int) (getenv("DB_PORT") ?: 3306); + $fp = @fsockopen($host, $port, $errno, $errstr, 1); + exit($fp === false ? 1 : 0); + '; then + echo "Datenbank erreichbar." + break + fi + i=$((i + 1)) + sleep 1 + done +fi + +exec "$@" diff --git a/docker/php.ini b/docker/php.ini new file mode 100644 index 0000000..7644529 --- /dev/null +++ b/docker/php.ini @@ -0,0 +1,32 @@ +; ------------------------------------------------------------- +; urlshort-qr-gen – PHP-Einstellungen im Container +; ------------------------------------------------------------- + +expose_php = Off + +; Logos duerfen bis 2 MB gross sein (siehe UPLOAD_MAX_BYTES). +upload_max_filesize = 4M +post_max_size = 8M +memory_limit = 256M +max_execution_time = 30 + +; Fehler ins Log, nicht in die Ausgabe. +display_errors = Off +log_errors = On +error_log = /dev/stderr + +; Sessions +session.cookie_httponly = On +session.cookie_samesite = "Lax" +session.use_strict_mode = On +session.use_only_cookies = On + +; Zeitzone (kann per APP_TIMEZONE ueberschrieben werden) +date.timezone = Europe/Berlin + +; OPcache fuer eine schnelle Weiterleitung +opcache.enable = 1 +opcache.memory_consumption = 96 +opcache.max_accelerated_files = 4000 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 15