# Rate limiting (per IP) — must be at http level, not inside server block limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/m; limit_req_zone $binary_remote_addr zone=dav_limit:10m rate=100r/m; # Webhook bursts (Square retry storms) need a higher ceiling than the API limit limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=120r/m; # Only redirect to HTTPS for non-local hosts, so local dev on :80 keeps working. # nginx map keys support regexes, which is how the RFC1918 ranges are matched. # Every regex is anchored with $ so a hostname like 127.0.0.1.evil.com cannot # match a private-IP prefix and bypass the HTTPS redirect. map $host $ssl_redirect { default 1; ~^localhost$ 0; ~^127\.\d+\.\d+\.\d+$ 0; ~^10\.\d+\.\d+\.\d+$ 0; ~^192\.168\.\d+\.\d+$ 0; ~^172\.(1[6-9]|2[0-9]|3[01])\.\d+\.\d+$ 0; ~^\[::1\]$ 0; } # Port 80: serve normally for localhost/private hosts, redirect everything else server { listen 80; server_name _; # Non-local hosts are forced to HTTPS; local/private hosts fall through # and are served normally below. if ($ssl_redirect) { return 301 https://$host$request_uri; } # Security headers add_header X-Content-Type-Options nosniff; add_header X-Frame-Options DENY; add_header X-XSS-Protection "1; mode=block"; # Square Web Payments SDK: script from *.squarecdn.com, card-entry iframe # from js.squareup.com (frame-src; without it the payment form cannot # tokenize behind this proxy). connect-src allows the SDK's own network calls. # 'unsafe-inline' in script-src is kept because the SvelteKit SPA emits inline # scripts; replace it with 'nonce-...' once the frontend supports nonces. add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://*.squarecdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.squareup.com https://*.squarecdn.com; frame-src https://js.squareup.com https://*.squareup.com; frame-ancestors 'none';" always; # Serve static frontend root /usr/share/nginx/html; index index.html; # Cache immutable assets aggressively location ~ ^/_app/immutable/ { expires 1y; add_header Cache-Control "public, immutable"; } # Normal frontend routes (SPA fallback) location / { try_files $uri /index.html; } # Square webhook — registered at the root in the Go app, NOT under /api/. # Exact match takes precedence over the static location / fallback. location = /webhooks/square { limit_req zone=webhook_limit burst=10 nodelay; proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # Proxy API requests to backend. No response caching here: the backend sends # no Cache-Control headers, so a shared cache keyed on URI alone would serve # one user's authenticated GETs to any caller. location /api/ { limit_req zone=api_limit burst=5 nodelay; proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # SabreDAV - CardDAV and CalDAV location /dav/ { limit_req zone=dav_limit burst=20 nodelay; # Important: rewrite to remove /dav prefix for PHP processing rewrite ^/dav/(.*)$ /server.php/$1 break; # Pass to PHP-FPM in sabredav container fastcgi_pass sabredav:9000; fastcgi_index server.php; fastcgi_split_path_info ^(.+\.php)(/.+)$; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /var/www/dav/server.php; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param REQUEST_URI $request_uri; # Required for DAV fastcgi_param HTTPS $https if_not_empty; fastcgi_read_timeout 300; fastcgi_buffering off; # Disable caching for DAV proxy_cache off; add_header Cache-Control "no-store, no-cache, must-revalidate"; # Allow DAV methods if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' '$http_origin'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCOL, MOVE, COPY'; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-None-Match,If-Modified-Since,Cache-Control,Content-Type,Range,Depth,Authorization,If-Match,Destination,Overwrite,Lock-Token,Timeout'; add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } # Remove security headers that interfere with DAV add_header X-Content-Type-Options "" always; add_header X-Frame-Options "" always; add_header X-XSS-Protection "" always; } # Legacy CardDAV endpoint (backward compatibility) location /carddav/ { return 301 $scheme://$host/dav/addressbooks$request_uri; } # Legacy CalDAV endpoint (backward compatibility) location /caldav/ { return 301 $scheme://$host/dav/calendars$request_uri; } } # Port 443: production TLS, adds HSTS server { listen 443 ssl http2; server_name _; # TLS certs (you'll mount them into /etc/nginx/certs) ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; # Security headers add_header X-Content-Type-Options nosniff; add_header X-Frame-Options DENY; add_header X-XSS-Protection "1; mode=block"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # Square Web Payments SDK: script from *.squarecdn.com, card-entry iframe # from js.squareup.com (frame-src; without it the payment form cannot # tokenize behind this proxy). connect-src allows the SDK's own network calls. # 'unsafe-inline' in script-src is kept because the SvelteKit SPA emits inline # scripts; replace it with 'nonce-...' once the frontend supports nonces. add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://*.squarecdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.squareup.com https://*.squarecdn.com; frame-src https://js.squareup.com https://*.squareup.com; frame-ancestors 'none';" always; # Serve static frontend root /usr/share/nginx/html; index index.html; # Cache immutable assets aggressively location ~ ^/_app/immutable/ { expires 1y; add_header Cache-Control "public, immutable"; } # Normal frontend routes (SPA fallback) location / { try_files $uri /index.html; } # Square webhook — registered at the root in the Go app, NOT under /api/. # Exact match takes precedence over the static location / fallback. location = /webhooks/square { limit_req zone=webhook_limit burst=10 nodelay; proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # Proxy API requests to backend. No response caching here: the backend sends # no Cache-Control headers, so a shared cache keyed on URI alone would serve # one user's authenticated GETs to any caller. location /api/ { limit_req zone=api_limit burst=5 nodelay; proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # SabreDAV - CardDAV and CalDAV location /dav/ { limit_req zone=dav_limit burst=20 nodelay; # Important: rewrite to remove /dav prefix for PHP processing rewrite ^/dav/(.*)$ /server.php/$1 break; # Pass to PHP-FPM in sabredav container fastcgi_pass sabredav:9000; fastcgi_index server.php; fastcgi_split_path_info ^(.+\.php)(/.+)$; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /var/www/dav/server.php; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param REQUEST_URI $request_uri; # Required for DAV fastcgi_param HTTPS $https if_not_empty; fastcgi_read_timeout 300; fastcgi_buffering off; # Disable caching for DAV proxy_cache off; add_header Cache-Control "no-store, no-cache, must-revalidate"; # Allow DAV methods if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' '$http_origin'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCOL, MOVE, COPY'; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-None-Match,If-Modified-Since,Cache-Control,Content-Type,Range,Depth,Authorization,If-Match,Destination,Overwrite,Lock-Token,Timeout'; add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } # Remove security headers that interfere with DAV add_header X-Content-Type-Options "" always; add_header X-Frame-Options "" always; add_header X-XSS-Protection "" always; } # Legacy CardDAV endpoint (backward compatibility) location /carddav/ { return 301 $scheme://$host/dav/addressbooks$request_uri; } # Legacy CalDAV endpoint (backward compatibility) location /caldav/ { return 301 $scheme://$host/dav/calendars$request_uri; } }