From 05f03a1ca1cb11c95620e758ba02defd1c6f2e23 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 21:14:32 +0000 Subject: [PATCH] feat: add Docker infrastructure from CLI Move production Docker files from core/cli to their proper home: - Dockerfile.app: PHP 8.3-FPM with Laravel extensions - Dockerfile.web: nginx reverse proxy - docker-compose.prod.yml: full stack (app, web, horizon, scheduler, mcp, redis, galera) - nginx/: default.conf + security-headers.conf - php/: opcache.ini + php-fpm.conf Co-Authored-By: Virgil --- docker/Dockerfile.app | 107 +++++++++++++++ docker/Dockerfile.web | 20 +++ docker/docker-compose.prod.yml | 200 +++++++++++++++++++++++++++++ docker/nginx/default.conf | 59 +++++++++ docker/nginx/security-headers.conf | 6 + docker/php/opcache.ini | 10 ++ docker/php/php-fpm.conf | 22 ++++ 7 files changed, 424 insertions(+) create mode 100644 docker/Dockerfile.app create mode 100644 docker/Dockerfile.web create mode 100644 docker/docker-compose.prod.yml create mode 100644 docker/nginx/default.conf create mode 100644 docker/nginx/security-headers.conf create mode 100644 docker/php/opcache.ini create mode 100644 docker/php/php-fpm.conf diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app new file mode 100644 index 0000000..a75b3fe --- /dev/null +++ b/docker/Dockerfile.app @@ -0,0 +1,107 @@ +# Host UK — Laravel Application Container +# PHP 8.3-FPM with all extensions required by the federated monorepo +# +# Build: docker build -f docker/Dockerfile.app -t host-uk/app:latest .. +# (run from host-uk/ workspace root, not core/) + +FROM php:8.3-fpm-alpine AS base + +# System dependencies +RUN apk add --no-cache \ + git \ + curl \ + libpng-dev \ + libjpeg-turbo-dev \ + freetype-dev \ + libwebp-dev \ + libzip-dev \ + icu-dev \ + oniguruma-dev \ + libxml2-dev \ + linux-headers \ + $PHPIZE_DEPS + +# PHP extensions +RUN docker-php-ext-configure gd \ + --with-freetype \ + --with-jpeg \ + --with-webp \ + && docker-php-ext-install -j$(nproc) \ + bcmath \ + exif \ + gd \ + intl \ + mbstring \ + opcache \ + pcntl \ + pdo_mysql \ + soap \ + xml \ + zip + +# Redis extension +RUN pecl install redis && docker-php-ext-enable redis + +# Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# PHP configuration +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +COPY docker/php/opcache.ini $PHP_INI_DIR/conf.d/opcache.ini +COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.d/zz-host-uk.conf + +# --- Build stage --- +FROM base AS build + +WORKDIR /app + +# Install dependencies first (cache layer) +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction + +# Copy application +COPY . . + +# Generate autoloader and run post-install +RUN composer dump-autoload --optimize --no-dev \ + && php artisan package:discover --ansi + +# Build frontend assets +RUN if [ -f package.json ]; then \ + apk add --no-cache nodejs npm && \ + npm ci --production=false && \ + npm run build && \ + rm -rf node_modules; \ + fi + +# --- Production stage --- +FROM base AS production + +WORKDIR /app + +# Copy built application +COPY --from=build /app /app + +# Create storage directories +RUN mkdir -p \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache + +# Permissions +RUN chown -R www-data:www-data storage bootstrap/cache + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD php-fpm-healthcheck || exit 1 + +USER www-data + +EXPOSE 9000 diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..e2f76c1 --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,20 @@ +# Host UK — Nginx Web Server +# Serves static files and proxies PHP to FPM container +# +# Build: docker build -f docker/Dockerfile.web -t host-uk/web:latest . + +FROM nginx:1.27-alpine + +# Copy nginx configuration +COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY docker/nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf + +# Copy static assets from app build +# (In production, these are volume-mounted from the app container) +# COPY --from=host-uk/app:latest /app/public /app/public + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost/health || exit 1 + +USER nginx +EXPOSE 80 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 0000000..7f25fa7 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,200 @@ +# Host UK Production Docker Compose +# Deployed to de.host.uk.com and de2.host.uk.com via Coolify +# +# Container topology per app server: +# app - PHP 8.3-FPM (all Laravel modules) +# web - Nginx (static files + FastCGI proxy) +# horizon - Laravel Horizon (queue worker) +# scheduler - Laravel scheduler +# mcp - Go MCP server +# redis - Redis 7 (local cache + sessions) +# galera - MariaDB 11 (Galera cluster node) + +services: + app: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/app:${TAG:-latest} + restart: unless-stopped + volumes: + - app-storage:/app/storage + environment: + - APP_ENV=production + - APP_DEBUG=false + - APP_URL=${APP_URL:-https://host.uk.com} + - DB_HOST=galera + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-hostuk} + - DB_USERNAME=${DB_USERNAME:-hostuk} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - CACHE_DRIVER=redis + - SESSION_DRIVER=redis + - QUEUE_CONNECTION=redis + depends_on: + redis: + condition: service_healthy + galera: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 + networks: + - app-net + + web: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/web:${TAG:-latest} + restart: unless-stopped + ports: + - "${WEB_PORT:-80}:80" + volumes: + - app-storage:/app/storage:ro + depends_on: + app: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/health"] + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 + networks: + - app-net + + horizon: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/app:${TAG:-latest} + restart: unless-stopped + command: php artisan horizon + volumes: + - app-storage:/app/storage + environment: + - APP_ENV=production + - DB_HOST=galera + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-hostuk} + - DB_USERNAME=${DB_USERNAME:-hostuk} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + depends_on: + app: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php artisan horizon:status | grep -q running"] + interval: 60s + timeout: 5s + start_period: 30s + retries: 3 + networks: + - app-net + + scheduler: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/app:${TAG:-latest} + restart: unless-stopped + command: php artisan schedule:work + volumes: + - app-storage:/app/storage + environment: + - APP_ENV=production + - DB_HOST=galera + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-hostuk} + - DB_USERNAME=${DB_USERNAME:-hostuk} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + depends_on: + app: + condition: service_healthy + networks: + - app-net + + mcp: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/core:${TAG:-latest} + restart: unless-stopped + command: core mcp serve + ports: + - "${MCP_PORT:-9001}:9000" + environment: + - MCP_ADDR=:9000 + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 9000 || exit 1"] + interval: 30s + timeout: 3s + retries: 3 + networks: + - app-net + + redis: + image: redis:7-alpine + restart: unless-stopped + command: > + redis-server + --maxmemory 512mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - app-net + + galera: + image: mariadb:11 + restart: unless-stopped + environment: + - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} + - MARIADB_DATABASE=${DB_DATABASE:-hostuk} + - MARIADB_USER=${DB_USERNAME:-hostuk} + - MARIADB_PASSWORD=${DB_PASSWORD} + - WSREP_CLUSTER_NAME=hostuk-galera + - WSREP_CLUSTER_ADDRESS=${GALERA_CLUSTER_ADDRESS:-gcomm://} + - WSREP_NODE_ADDRESS=${GALERA_NODE_ADDRESS} + - WSREP_NODE_NAME=${GALERA_NODE_NAME} + - WSREP_SST_METHOD=mariabackup + command: > + --wsrep-on=ON + --wsrep-provider=/usr/lib/galera/libgalera_smm.so + --wsrep-cluster-name=hostuk-galera + --wsrep-cluster-address=${GALERA_CLUSTER_ADDRESS:-gcomm://} + --wsrep-node-address=${GALERA_NODE_ADDRESS} + --wsrep-node-name=${GALERA_NODE_NAME} + --wsrep-sst-method=mariabackup + --binlog-format=ROW + --default-storage-engine=InnoDB + --innodb-autoinc-lock-mode=2 + --innodb-buffer-pool-size=1G + --innodb-log-file-size=256M + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + volumes: + - galera-data:/var/lib/mysql + ports: + - "${GALERA_PORT:-3306}:3306" + - "4567:4567" + - "4568:4568" + - "4444:4444" + healthcheck: + test: ["CMD-SHELL", "mariadb -u root -p${DB_ROOT_PASSWORD} -e 'SHOW STATUS LIKE \"wsrep_ready\"' | grep -q ON"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 5 + networks: + - app-net + +volumes: + app-storage: + redis-data: + galera-data: + +networks: + app-net: + driver: bridge diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..b05018e --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,59 @@ +# Host UK Nginx Configuration +# Proxies PHP to the app (FPM) container, serves static files directly + +server { + listen 80; + server_name _; + + root /app/public; + index index.php; + + charset utf-8; + + # Security headers + include /etc/nginx/snippets/security-headers.conf; + + # Health check endpoint (no logging) + location = /health { + access_log off; + try_files $uri /index.php?$query_string; + } + + # Static file caching + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + # Laravel application + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # PHP-FPM upstream + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + + fastcgi_hide_header X-Powered-By; + fastcgi_buffer_size 32k; + fastcgi_buffers 16 16k; + fastcgi_read_timeout 300; + + # Pass real client IP from LB proxy protocol + fastcgi_param REMOTE_ADDR $http_x_forwarded_for; + } + + # Block dotfiles (except .well-known) + location ~ /\.(?!well-known) { + deny all; + } + + # Block access to sensitive files + location ~* \.(env|log|yaml|yml|toml|lock|bak|sql)$ { + deny all; + } +} diff --git a/docker/nginx/security-headers.conf b/docker/nginx/security-headers.conf new file mode 100644 index 0000000..3917d7a --- /dev/null +++ b/docker/nginx/security-headers.conf @@ -0,0 +1,6 @@ +# Security headers for Host UK +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-XSS-Protection "1; mode=block" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; diff --git a/docker/php/opcache.ini b/docker/php/opcache.ini new file mode 100644 index 0000000..61a65c1 --- /dev/null +++ b/docker/php/opcache.ini @@ -0,0 +1,10 @@ +; OPcache configuration for production +opcache.enable=1 +opcache.memory_consumption=256 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=20000 +opcache.validate_timestamps=0 +opcache.save_comments=1 +opcache.fast_shutdown=1 +opcache.jit_buffer_size=128M +opcache.jit=1255 diff --git a/docker/php/php-fpm.conf b/docker/php/php-fpm.conf new file mode 100644 index 0000000..c19e21c --- /dev/null +++ b/docker/php/php-fpm.conf @@ -0,0 +1,22 @@ +; Host UK PHP-FPM pool configuration +[www] +pm = dynamic +pm.max_children = 50 +pm.start_servers = 10 +pm.min_spare_servers = 5 +pm.max_spare_servers = 20 +pm.max_requests = 1000 +pm.process_idle_timeout = 10s + +; Status page for health checks +pm.status_path = /fpm-status +ping.path = /fpm-ping +ping.response = pong + +; Logging +access.log = /proc/self/fd/2 +slowlog = /proc/self/fd/2 +request_slowlog_timeout = 5s + +; Security +security.limit_extensions = .php