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