From 77b2dc5dd7d6354f54cffa634ccc169830e5be44 Mon Sep 17 00:00:00 2001 From: Michael Schiemer Date: Fri, 31 Oct 2025 23:52:30 +0100 Subject: [PATCH] feat: Add staging environment setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create staging stack with separate containers and volumes - Configure staging.michaelschiemer.de subdomain routing - Add deploy-staging.yml workflow for auto-deployment - Extend build-image.yml to support staging branch - Separate Redis instance and network for staging - Staging uses staging branch by default Features: - Auto-deploy: Push to staging branch → build → deploy to staging - Separate from production: Different containers, volumes, networks - Shared Traefik: Uses same SSL certificates (*.michaelschiemer.de) - Testing environment before production deployment --- .gitea/workflows/build-image.yml | 2 +- .gitea/workflows/deploy-staging.yml | 210 ++++++++++++++ deployment/stacks/staging/README.md | 164 +++++++++++ deployment/stacks/staging/docker-compose.yml | 262 ++++++++++++++++++ .../stacks/staging/nginx/conf.d/default.conf | 129 +++++++++ 5 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy-staging.yml create mode 100644 deployment/stacks/staging/README.md create mode 100644 deployment/stacks/staging/docker-compose.yml create mode 100644 deployment/stacks/staging/nginx/conf.d/default.conf diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index 444ca7cb..b0d4b268 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -4,7 +4,7 @@ run-name: Build Image - ${{ github.ref_name }} - ${{ github.sha }} on: push: - branches: [ main, develop ] + branches: [ main, develop, staging ] paths-ignore: - '**.md' - 'docs/**' diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 00000000..a45b9535 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,210 @@ +name: Deploy to Staging + +run-name: Deploy to Staging - ${{ github.ref_name }} + +on: + workflow_dispatch: + inputs: + image_tag: + description: 'Image tag to deploy (leave empty for latest)' + required: false + default: 'latest' + branch: + description: 'Branch to deploy from' + required: false + default: 'staging' + auto_deploy: + description: 'Auto-deploy after successful build' + type: boolean + required: false + default: false + workflow_run: + workflows: ["Build Docker Image"] + types: + - completed + branches: [staging] + +env: + REGISTRY: registry.michaelschiemer.de + IMAGE_NAME: framework + DEPLOYMENT_HOST: 94.16.110.151 + +jobs: + deploy: + name: Deploy to Staging Server + runs-on: ubuntu-latest + environment: + name: staging + url: https://staging.michaelschiemer.de + # Only run if triggered manually OR if build workflow succeeded on staging branch + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'staging') + steps: + - name: Checkout deployment scripts + run: | + REF_NAME="${{ github.ref_name || inputs.branch || 'staging' }}" + REPO="${{ github.repository }}" + + if [ -n "${{ secrets.CI_TOKEN }}" ]; then + git clone --depth 1 --branch "$REF_NAME" \ + "https://${{ secrets.CI_TOKEN }}@git.michaelschiemer.de/${REPO}.git" \ + /workspace/repo + else + git clone --depth 1 --branch "$REF_NAME" \ + "https://git.michaelschiemer.de/${REPO}.git" \ + /workspace/repo || \ + git clone --depth 1 \ + "https://git.michaelschiemer.de/${REPO}.git" \ + /workspace/repo + fi + + cd /workspace/repo + + - name: Determine image tag + id: image_tag + shell: bash + run: | + # Priority: + # 1. Manual input (workflow_dispatch) + # 2. From workflow_run (build workflow outputs) + # 3. Latest + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.image_tag }}" ]; then + IMAGE_TAG="${{ inputs.image_tag }}" + echo "Using manually specified tag: $IMAGE_TAG" + elif [ "${{ github.event_name }}" = "workflow_run" ]; then + # Use latest for staging auto-deploy + IMAGE_TAG="latest" + echo "Using latest tag (from workflow_run trigger)" + else + IMAGE_TAG="latest" + echo "Using latest tag (default)" + fi + + echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_OUTPUT + echo "📦 Deploying image tag: $IMAGE_TAG" + + - name: Setup SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/production + chmod 600 ~/.ssh/production + ssh-keyscan -H ${{ env.DEPLOYMENT_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy via SSH + run: | + set -e + + DEPLOYMENT_HOST="${{ env.DEPLOYMENT_HOST }}" + REGISTRY="${{ env.REGISTRY }}" + IMAGE_NAME="${{ env.IMAGE_NAME }}" + IMAGE_TAG="${{ steps.image_tag.outputs.IMAGE_TAG }}" + + FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + STACK_PATH="~/deployment/stacks/staging" + + echo "🚀 Starting staging deployment..." + echo " Image: ${FULL_IMAGE}" + echo " Tag: ${IMAGE_TAG}" + echo " Host: ${DEPLOYMENT_HOST}" + echo " Stack: ${STACK_PATH}" + + ssh -i ~/.ssh/production \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + deploy@${DEPLOYMENT_HOST} </dev/null || true + docker network create staging-internal 2>/dev/null || true + + echo "🔄 Starting/updating services..." + docker compose up -d --pull always --force-recreate || { + echo "❌ Failed to start services" + exit 1 + } + + echo "⏳ Waiting for services to start..." + sleep 10 + + echo "📊 Container status:" + docker compose ps + + echo "✅ Staging deployment completed!" + EOF + + - name: Wait for deployment to stabilize + run: sleep 30 + + - name: Health check + id: health + run: | + for i in {1..10}; do + if curl -f -k https://staging.michaelschiemer.de/health; then + echo "✅ Health check passed" + exit 0 + fi + echo "⏳ Waiting for staging service... (attempt $i/10)" + sleep 10 + done + echo "❌ Health check failed" + exit 1 + + - name: Rollback on failure + if: failure() && steps.health.outcome == 'failure' + run: | + echo "⚠️ Staging deployment failed - manual rollback may be required" + echo "💡 To rollback manually, SSH to the server and run:" + echo " cd ~/deployment/stacks/staging" + echo " docker compose down" + echo " git checkout docker-compose.yml" + echo " docker compose up -d" + + - name: Notify deployment success + if: success() + run: | + echo "🚀 Staging deployment successful!" + echo "URL: https://staging.michaelschiemer.de" + echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.image_tag.outputs.IMAGE_TAG }}" + + - name: Notify deployment failure + if: failure() + run: | + echo "❌ Staging deployment failed" + # TODO: Add Slack/Email notification diff --git a/deployment/stacks/staging/README.md b/deployment/stacks/staging/README.md new file mode 100644 index 00000000..bb04c153 --- /dev/null +++ b/deployment/stacks/staging/README.md @@ -0,0 +1,164 @@ +# Staging Stack - Staging Environment + +## Overview + +Separate staging environment for testing features before production deployment. + +**URL**: https://staging.michaelschiemer.de + +**Features**: +- Separate containers and volumes from production +- Auto-deployment from `staging` branch +- Shared Traefik network (same SSL certificates) +- Separate Redis instance +- Can use separate database or share with production + +## Services + +- **staging-app** (PHP-FPM) - Application runtime +- **staging-nginx** (Nginx) - Web server for staging.michaelschiemer.de +- **staging-redis** (Redis 7) - Separate cache/session/queue backend +- **staging-queue-worker** - Background job processor +- **staging-scheduler** - Cron job executor + +## Prerequisites + +1. **Traefik Stack Running** (shared with production) +2. **DNS Configuration**: `staging.michaelschiemer.de` points to server IP +3. **Docker Networks**: + ```bash + docker network create traefik-public # Shared with production + docker network create staging-internal # Staging-only + ``` + +## Configuration + +### 1. Create Environment File + +```bash +cp .env.example .env +``` + +### 2. Configure Environment Variables + +```env +# Staging-specific +APP_ENV=staging +APP_DEBUG=true +APP_URL=https://staging.michaelschiemer.de + +# Database (can use separate staging DB or share) +DB_DATABASE=michaelschiemer_staging +DB_HOST=postgres +DB_PORT=5432 +DB_USERNAME=postgres +DB_PASSWORD= + +# Redis (separate instance) +REDIS_PASSWORD= +CACHE_PREFIX=staging + +# Git +GIT_REPOSITORY_URL=https://git.michaelschiemer.de/michael/michaelschiemer.git +GIT_BRANCH=staging +GIT_TOKEN= +``` + +## Deployment + +### Initial Setup + +```bash +# Create staging stack directory on server +mkdir -p ~/deployment/stacks/staging + +# Copy docker-compose.yml from repository +cp deployment/stacks/staging/docker-compose.yml ~/deployment/stacks/staging/ + +# Create .env file +cd ~/deployment/stacks/staging +cp .env.example .env +# Edit .env with staging-specific values + +# Ensure networks exist +docker network create traefik-public 2>/dev/null || true +docker network create staging-internal 2>/dev/null || true + +# Start staging stack +docker compose up -d +``` + +### Auto-Deployment + +**Automatic**: Push to `staging` branch triggers build, then auto-deploys to staging. + +**Manual**: Use "Deploy to Staging" workflow in Gitea Actions. + +### Verify Deployment + +```bash +# Check container status +cd ~/deployment/stacks/staging +docker compose ps + +# Test staging URL +curl https://staging.michaelschiemer.de/health + +# View logs +docker compose logs -f staging-app +docker compose logs -f staging-nginx +``` + +## Differences from Production + +| Aspect | Production | Staging | +|--------|-----------|---------| +| **Domain** | michaelschiemer.de | staging.michaelschiemer.de | +| **Branch** | main | staging | +| **APP_ENV** | production | staging | +| **APP_DEBUG** | false | true | +| **Redis** | Separate instance | Separate instance | +| **Database** | michaelschiemer | michaelschiemer_staging | +| **Volumes** | app-* | staging-* | +| **Network** | app-internal | staging-internal | + +## Workflow + +1. **Development**: Feature branch → merge to `staging` +2. **Auto-Build**: Push to `staging` triggers `build-image.yml` +3. **Auto-Deploy**: Successful build auto-deploys to staging (via `deploy-staging.yml`) +4. **Testing**: Test on https://staging.michaelschiemer.de +5. **Production**: Merge `staging` → `main` → deploy to production + +## Troubleshooting + +### Staging not accessible + +```bash +# Check Traefik routing +docker logs traefik | grep staging + +# Verify container is running +docker ps | grep staging + +# Check nginx logs +docker logs staging-nginx +``` + +### Code not updating + +```bash +# Force code pull in staging-app +docker exec staging-app bash -c "cd /var/www/html && git pull origin staging" +docker compose restart staging-app staging-nginx +``` + +## Cleanup + +To remove staging environment: + +```bash +cd ~/deployment/stacks/staging +docker compose down -v # Removes volumes too +docker network rm staging-internal +``` diff --git a/deployment/stacks/staging/docker-compose.yml b/deployment/stacks/staging/docker-compose.yml new file mode 100644 index 00000000..22f90a0f --- /dev/null +++ b/deployment/stacks/staging/docker-compose.yml @@ -0,0 +1,262 @@ +# Staging Environment - Docker Compose Configuration +# Separate stack for staging.michaelschiemer.de + +services: + # PHP-FPM Application Runtime + staging-app: + image: git.michaelschiemer.de:5000/framework:latest + container_name: staging-app + restart: unless-stopped + networks: + - staging-internal + environment: + - TZ=Europe/Berlin + - APP_ENV=staging + - APP_DEBUG=${APP_DEBUG:-true} + - APP_URL=https://staging.michaelschiemer.de + - APP_KEY=${APP_KEY:-} + # Git Repository - clones staging branch + - GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-} + - GIT_BRANCH=staging + - GIT_TOKEN=${GIT_TOKEN:-} + - GIT_USERNAME=${GIT_USERNAME:-} + - GIT_PASSWORD=${GIT_PASSWORD:-} + # Database (can share with production or use separate) + - DB_HOST=${DB_HOST:-postgres} + - DB_PORT=${DB_PORT:-5432} + - DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + # Redis + - REDIS_HOST=staging-redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD} + # Cache + - CACHE_DRIVER=redis + - CACHE_PREFIX=${CACHE_PREFIX:-staging} + # Session + - SESSION_DRIVER=redis + - SESSION_LIFETIME=${SESSION_LIFETIME:-120} + # Queue + - QUEUE_DRIVER=redis + - QUEUE_CONNECTION=default + volumes: + - staging-code:/var/www/html + - staging-storage:/var/www/html/storage + - staging-logs:/var/www/html/storage/logs + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + healthcheck: + test: ["CMD-SHELL", "true"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + depends_on: + staging-redis: + condition: service_started + + # Nginx Web Server + staging-nginx: + image: git.michaelschiemer.de:5000/framework:latest + container_name: staging-nginx + restart: unless-stopped + networks: + - traefik-public + - staging-internal + environment: + - TZ=Europe/Berlin + - APP_ENV=staging + - APP_DEBUG=${APP_DEBUG:-true} + # Git Repository - clones staging branch + - GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-} + - GIT_BRANCH=staging + - GIT_TOKEN=${GIT_TOKEN:-} + - GIT_USERNAME=${GIT_USERNAME:-} + - GIT_PASSWORD=${GIT_PASSWORD:-} + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - staging-code:/var/www/html:ro + - staging-storage:/var/www/html/storage:ro + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + # Wait for code to be available (cloned by staging-app container) then start nginx + entrypoint: ["/bin/sh", "-c"] + command: + - | + # Wait for code to be available in shared volume (staging-app container clones it) + GIT_TARGET_DIR="/var/www/html" + echo "⏳ [staging-nginx] Waiting for code to be available in shared volume..." + for i in 1 2 3 4 5 6 7 8 9 10; do + if [ -d "$$GIT_TARGET_DIR/public" ]; then + echo "✅ [staging-nginx] Code found in shared volume" + break + fi + echo " [staging-nginx] Waiting... ($$i/10)" + sleep 2 + done + + # If code still not available after wait, try to copy from image as fallback + if [ ! -d "$$GIT_TARGET_DIR/public" ] && [ -d "/var/www/html.orig" ]; then + echo "⚠️ [staging-nginx] Code not found in shared volume, copying from image..." + find /var/www/html.orig -mindepth 1 -maxdepth 1 ! -name "storage" -exec cp -r {} "$$GIT_TARGET_DIR/" \; 2>/dev/null || true + fi + + # Start nginx only (no PHP-FPM, no Git clone - staging-app container handles that) + echo "🚀 [staging-nginx] Starting nginx..." + exec nginx -g "daemon off;" + labels: + - "traefik.enable=true" + # HTTP Router for staging subdomain + - "traefik.http.routers.staging.rule=Host(`staging.michaelschiemer.de`)" + - "traefik.http.routers.staging.entrypoints=websecure" + - "traefik.http.routers.staging.tls=true" + - "traefik.http.routers.staging.tls.certresolver=letsencrypt" + # Service + - "traefik.http.services.staging.loadbalancer.server.port=80" + # Middleware + - "traefik.http.routers.staging.middlewares=default-chain@file" + # Network + - "traefik.docker.network=traefik-public" + healthcheck: + test: ["CMD-SHELL", "curl -f http://127.0.0.1/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + depends_on: + staging-app: + condition: service_started + + # Redis Cache/Session/Queue Backend (separate from production) + staging-redis: + image: redis:7-alpine + container_name: staging-redis + restart: unless-stopped + networks: + - staging-internal + environment: + - TZ=Europe/Berlin + command: > + redis-server + --requirepass ${REDIS_PASSWORD} + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --save 900 1 + --save 300 10 + --save 60 10000 + --appendonly yes + --appendfsync everysec + volumes: + - staging-redis-data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + healthcheck: + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Queue Worker (Background Jobs) + staging-queue-worker: + image: git.michaelschiemer.de:5000/framework:latest + container_name: staging-queue-worker + restart: unless-stopped + networks: + - staging-internal + environment: + - TZ=Europe/Berlin + - APP_ENV=staging + - APP_DEBUG=${APP_DEBUG:-true} + # Database + - DB_HOST=${DB_HOST:-postgres} + - DB_PORT=${DB_PORT:-5432} + - DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + # Redis + - REDIS_HOST=staging-redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD} + # Queue + - QUEUE_DRIVER=redis + - QUEUE_CONNECTION=default + - QUEUE_WORKER_SLEEP=${QUEUE_WORKER_SLEEP:-3} + - QUEUE_WORKER_TRIES=${QUEUE_WORKER_TRIES:-3} + - QUEUE_WORKER_TIMEOUT=${QUEUE_WORKER_TIMEOUT:-60} + volumes: + - staging-code:/var/www/html + - staging-storage:/var/www/html/storage + - staging-logs:/var/www/html/storage/logs + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + command: php console.php queue:work --queue=default --timeout=${QUEUE_WORKER_TIMEOUT:-60} + healthcheck: + test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + depends_on: + staging-app: + condition: service_started + staging-redis: + condition: service_started + + # Scheduler (Cron Jobs) + staging-scheduler: + image: git.michaelschiemer.de:5000/framework:latest + container_name: staging-scheduler + restart: unless-stopped + networks: + - staging-internal + environment: + - TZ=Europe/Berlin + - APP_ENV=staging + - APP_DEBUG=${APP_DEBUG:-true} + # Database + - DB_HOST=${DB_HOST:-postgres} + - DB_PORT=${DB_PORT:-5432} + - DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + # Redis + - REDIS_HOST=staging-redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD} + volumes: + - staging-code:/var/www/html + - staging-storage:/var/www/html/storage + - staging-logs:/var/www/html/storage/logs + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + command: php console.php scheduler:run + healthcheck: + test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + depends_on: + staging-app: + condition: service_started + staging-redis: + condition: service_started + +volumes: + staging-code: + name: staging-code + staging-storage: + name: staging-storage + staging-logs: + name: staging-logs + staging-redis-data: + name: staging-redis-data + +networks: + traefik-public: + external: true + staging-internal: + external: false + name: staging-internal diff --git a/deployment/stacks/staging/nginx/conf.d/default.conf b/deployment/stacks/staging/nginx/conf.d/default.conf new file mode 100644 index 00000000..e8f5577d --- /dev/null +++ b/deployment/stacks/staging/nginx/conf.d/default.conf @@ -0,0 +1,129 @@ +# Nginx Configuration for PHP-FPM Application +# Optimized for production with security headers and performance tuning + +# Upstream PHP-FPM +upstream php-upstream { + server staging-app:9000; +} + +# Rate limiting zones +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=general_limit:10m rate=30r/s; + +server { + listen 80; + server_name _; + + root /var/www/html/public; + index index.php index.html; + + # Security Headers (additional to Traefik middleware) + 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; + + # Client body size limit (adjust for file uploads) + client_max_body_size 100M; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/javascript + application/json + application/javascript + application/x-javascript + text/xml + application/xml + application/xml+rss + image/svg+xml; + + # Logging + access_log /var/log/nginx/access.log combined; + error_log /var/log/nginx/error.log warn; + + # Health check endpoint (for Docker healthcheck) + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Deny access to sensitive files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + location ~ ^/(\.env|\.git|\.gitignore|\.gitattributes|composer\.(json|lock)|package(-lock)?\.json) { + deny all; + access_log off; + log_not_found off; + } + + # Static files - serve directly for performance + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot|webp)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + # API endpoints - rate limited + location ^~ /api/ { + limit_req zone=api_limit burst=20 nodelay; + limit_req_status 429; + + try_files $uri $uri/ /index.php?$query_string; + } + + # PHP-FPM processing + location ~ \.php$ { + limit_req zone=general_limit burst=50 nodelay; + + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + + fastcgi_pass php-upstream; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + + # FastCGI parameters + include fastcgi_params; + + # PHP-FPM timeouts + fastcgi_connect_timeout 60s; + fastcgi_send_timeout 180s; + fastcgi_read_timeout 180s; + + # Buffer settings + fastcgi_buffer_size 128k; + fastcgi_buffers 256 16k; + fastcgi_busy_buffers_size 256k; + fastcgi_temp_file_write_size 256k; + + # Hide PHP version + fastcgi_hide_header X-Powered-By; + } + + # Fallback to index.php for non-existent files (framework routing) + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Deny access to storage directory (except public subdirectory) + location ^~ /storage { + deny all; + } + + location ^~ /storage/public { + allow all; + } +}