c83e88348f
Default nginx is 4 8k — too tight when an outer Cloudflare / corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when a power-user accumulates many per-gallery gallery_token_<slug> cookies over the 24h maxAge in tokenUtils.js. Either way users hit "400 Request Header Or Cookie Too Large" and clearing cookies is the only workaround. 4×32k is cheap RAM, matches what most reverse proxies do upstream, and means PicPeak doesn't fail the request before the upstream even sees it.
239 lines
11 KiB
Nginx Configuration File
239 lines
11 KiB
Nginx Configuration File
# Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM,
|
||
# Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when
|
||
# the header is absent (direct access / no outer proxy). Without this the
|
||
# inner nginx was always forwarding "http" to the backend because the outer
|
||
# proxy → inner nginx hop is plain HTTP, breaking Secure cookies and HTTPS
|
||
# URL generation in the backend. See issue #547.
|
||
map $http_x_forwarded_proto $real_proto {
|
||
default $http_x_forwarded_proto;
|
||
"" $scheme;
|
||
}
|
||
|
||
server {
|
||
listen 80;
|
||
server_name localhost;
|
||
server_tokens off;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
# Docker DNS resolver for dynamic service discovery (required for Swarm/Compose)
|
||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||
resolver_timeout 5s;
|
||
|
||
# Allow larger file uploads (up to 1GB for video support)
|
||
client_max_body_size 1G;
|
||
client_body_timeout 300s;
|
||
|
||
# Defensive header buffer bump (#591). Default `4 8k` is too tight when
|
||
# an outer Cloudflare / corp-proxy sits in front and injects long
|
||
# Set-Cookie / X-Forwarded-* headers, or when a power-user accumulates
|
||
# many per-gallery `gallery_token_<slug>` cookies over the 24h maxAge
|
||
# in tokenUtils.js. Either way users hit "400 Request Header Or Cookie
|
||
# Too Large" and clearing cookies is the only fix. 4×32k is cheap RAM
|
||
# and matches what most reverse proxies already do upstream.
|
||
large_client_header_buffers 4 32k;
|
||
|
||
# Gzip compression
|
||
gzip on;
|
||
gzip_vary on;
|
||
gzip_min_length 1024;
|
||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||
|
||
# Security headers
|
||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
|
||
|
||
# Strip the same headers when emitted by the upstream backend so nginx
|
||
# is the single source. Without this, helmet (in the Express app) and
|
||
# nginx both emit the headers and clients see duplicates — testssl
|
||
# flagged "Multiple X-Frame-Options / X-Content-Type-Options / CSP /
|
||
# Permissions-Policy / Referrer-Policy headers" on the live origin.
|
||
# proxy_hide_header at server level applies to every proxy_pass below.
|
||
proxy_hide_header X-Frame-Options;
|
||
proxy_hide_header X-Content-Type-Options;
|
||
proxy_hide_header Referrer-Policy;
|
||
proxy_hide_header Content-Security-Policy;
|
||
proxy_hide_header Permissions-Policy;
|
||
proxy_hide_header Strict-Transport-Security;
|
||
|
||
# Health check endpoint
|
||
location /health {
|
||
access_log off;
|
||
return 200 "healthy\n";
|
||
add_header Content-Type text/plain;
|
||
}
|
||
|
||
# Cache static assets
|
||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||
expires 1y;
|
||
add_header Cache-Control "public, immutable";
|
||
# Re-apply security headers (add_header in location block overrides server-level)
|
||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
|
||
}
|
||
|
||
# Cache index.html with revalidation
|
||
location = /index.html {
|
||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||
add_header Pragma "no-cache";
|
||
add_header Expires "0";
|
||
# Re-apply security headers
|
||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
|
||
}
|
||
|
||
# API proxy
|
||
location /api {
|
||
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection 'upgrade';
|
||
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 $real_proto;
|
||
proxy_cache_bypass $http_upgrade;
|
||
proxy_read_timeout 86400;
|
||
|
||
# Allow larger uploads for API endpoints (up to 1GB for video support)
|
||
client_max_body_size 1G;
|
||
client_body_timeout 300s;
|
||
}
|
||
|
||
# Photo serving proxy
|
||
location /photos {
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000;
|
||
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 $real_proto;
|
||
|
||
# Cache photos
|
||
proxy_cache_valid 200 302 1d;
|
||
proxy_cache_valid 404 1m;
|
||
}
|
||
|
||
# Thumbnail serving proxy
|
||
location /thumbnails {
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000;
|
||
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 $real_proto;
|
||
|
||
# Cache thumbnails
|
||
proxy_cache_valid 200 302 7d;
|
||
proxy_cache_valid 404 1m;
|
||
}
|
||
|
||
# Uploads serving proxy (logos, favicons, watermarks)
|
||
# ^~ modifier stops regex matching, ensuring uploads are proxied not served locally
|
||
location ^~ /uploads {
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000;
|
||
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 $real_proto;
|
||
|
||
# Cache uploads
|
||
proxy_cache_valid 200 302 7d;
|
||
proxy_cache_valid 404 1m;
|
||
}
|
||
|
||
# Self-hosted webfonts proxy (bundled families + admin user additions).
|
||
# ^~ modifier stops regex matching, ensuring fonts are proxied to the
|
||
# backend (which scans backend/assets/fonts and STORAGE_PATH/fonts) and
|
||
# NOT served locally — the .woff2 files do not exist in the frontend image.
|
||
location ^~ /fonts {
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000;
|
||
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 $real_proto;
|
||
|
||
# Fonts rarely change; cache aggressively (matches backend Cache-Control).
|
||
proxy_cache_valid 200 302 7d;
|
||
proxy_cache_valid 404 1m;
|
||
}
|
||
|
||
# Dynamic robots.txt served by backend
|
||
location = /robots.txt {
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000/robots.txt;
|
||
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 $real_proto;
|
||
}
|
||
|
||
# Delegate root requests to backend for public landing page handling
|
||
location = / {
|
||
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection 'upgrade';
|
||
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 $real_proto;
|
||
proxy_read_timeout 60s;
|
||
}
|
||
|
||
# Social-crawler detection for gallery share URLs. Crawlers (WhatsApp,
|
||
# Facebook, Slack, Twitter, etc.) don't run JS, so the SPA's client-side
|
||
# meta tags never reach them. Route those UAs to backend's /og handler
|
||
# via internal rewrite; humans fall through to the SPA via try_files.
|
||
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
|
||
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
|
||
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
|
||
# and LinkPreview / Slack-ImgProxy added in #521 to catch
|
||
# business-API preview fetchers that aren't the main WhatsApp app.
|
||
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") {
|
||
rewrite ^ /og/gallery/$gallery_slug last;
|
||
}
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
|
||
# OG preview endpoint (proxied to backend). Public endpoint by design —
|
||
# only exposes event_name + branding logo, no protected photo content.
|
||
location ^~ /og/gallery/ {
|
||
set $backend_upstream backend;
|
||
proxy_pass http://$backend_upstream:3000;
|
||
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 $real_proto;
|
||
}
|
||
|
||
# SPA fallback
|
||
location / {
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
|
||
# Deny access to hidden files
|
||
location ~ /\. {
|
||
deny all;
|
||
}
|
||
}
|