# nginx config for serving SecureBit.chat (static PWA) on Fly.io. # Mirrors the behavior of the Apache .htaccess: correct JS MIME for ES modules # (.jsx/.mjs), no-cache for the app shell / service worker / versioning files, # long-immutable cache for hashed/static assets, security headers, SPA fallback. worker_processes auto; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # ES modules must be served as JavaScript. nginx's default mime.types maps # .js but not .mjs/.jsx — declare them explicitly (this overrides .js too). types { application/javascript js mjs jsx; text/css css; application/json json map; application/manifest+json webmanifest; font/woff2 woff2; font/woff woff; image/svg+xml svg; } sendfile on; tcp_nopush on; server_tokens off; gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_min_length 256; gzip_types text/plain text/css text/javascript application/javascript application/json application/manifest+json application/ld+json application/wasm image/svg+xml image/x-icon font/woff2; # Decide Cache-Control from the request path. Keeping all add_header calls at # one level avoids nginx's header-inheritance reset between blocks. map $uri $sb_cache { default "public, max-age=31536000, immutable"; ~^/index\.html$ "no-cache, no-store, must-revalidate"; ~^/$ "no-cache, no-store, must-revalidate"; ~^/sw\.js$ "no-cache, no-store, must-revalidate"; ~^/manifest\.json$ "no-cache, no-store, must-revalidate"; ~^/meta\.json$ "no-cache, no-store, must-revalidate"; # Operator ICE/TURN config: must never be cached long, or the browser # locks onto a stale server list (default was max-age=1y immutable). ~^/config/ice-servers\.js$ "no-cache, no-store, must-revalidate"; # dist/ bundles are query-versioned (?v=) in index.html. "no-cache" forces # revalidation on every load, but dropping "no-store" lets the browser reuse # the cached copy on a 304 — avoiding a full re-download of the large bundles # (e.g. qr-local.js ~1.2MB) when nothing changed, while still picking up # new releases immediately. ~^/dist/ "no-cache, must-revalidate"; # The install prompt is a plain browser module and imports the dictionary by a # bare path, so that import carries no ?v= to bust the cache with. Left on the # default one-year immutable rule below, a visitor would keep the translations # they first loaded for a year. ~^/src/i18n/ "no-cache, must-revalidate"; # Crawler-facing files must stay re-fetchable: the default below is a # one-year immutable cache, which would freeze a sitemap for a year. ~^/robots\.txt$ "public, max-age=3600"; ~^/sitemap\.xml$ "public, max-age=3600"; } # Which app shell an unmatched navigation falls back to. A request under /de/ must # land on /de/index.html, not the English one — otherwise every deep link into a # localized page silently switches language. The entries between the markers are # rewritten by scripts/build-i18n.js from locales/site.json. map $uri $sb_shell { default /index.html; # BEGIN generated locale shells ~^/de/ /de/index.html; ~^/fr/ /fr/index.html; ~^/es/ /es/index.html; ~^/uk/ /uk/index.html; ~^/ru/ /ru/index.html; ~^/zh/ /zh/index.html; ~^/ko/ /ko/index.html; ~^/hi/ /hi/index.html; ~^/ar/ /ar/index.html; ~^/he/ /he/index.html; ~^/fa/ /fa/index.html; ~^/ur/ /ur/index.html; # END generated locale shells } # CDN-Cache-Control is read by Cloudflare (and other CDNs) *independently* of the # browser Cache-Control above, and takes precedence at the edge. We force "no-store" # for the app shell, service worker and version files so a CDN can never serve a # stale meta.json / index.html / sw.js — which would silently break the in-app # update notification. The empty default emits no header (nginx skips empty values), # so immutable hashed assets keep being edge-cached normally for performance. map $uri $sb_cdn_cache { default ""; ~^/index\.html$ "no-store"; ~^/$ "no-store"; ~^/sw\.js$ "no-store"; ~^/manifest\.json$ "no-store"; ~^/meta\.json$ "no-store"; ~^/config/ice-servers\.js$ "no-store"; ~^/dist/ "no-store"; ~^/src/i18n/ "no-store"; } server { listen 8080 default_server; listen [::]:8080 default_server; server_name _; root /usr/share/nginx/html; index index.html; # Security headers (frame-ancestors complements the in-page CSP meta tag). add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header X-Frame-Options "DENY" always; add_header Content-Security-Policy "frame-ancestors 'none';" always; # Force HTTPS for two years and preload, closing the first-visit SSL-strip # window that upgrade-insecure-requests alone does not cover. add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; # Lock down powerful features. Camera + microphone are allowed for QR # scanning and encrypted voice/video calls; other sensors are denied. add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always; add_header Cache-Control $sb_cache always; # Edge-cache directive for Cloudflare/CDNs (empty value → header is omitted). add_header CDN-Cache-Control $sb_cdn_cache always; add_header Service-Worker-Allowed "/" always; # Real asset files must return 404 when missing — never fall back to the # HTML shell, which would be served with the wrong content type and break # module/script loading (e.g. a missing config/ice-servers.js). location ~* \.(js|mjs|jsx|css|json|map|xml|txt|woff2?|ttf|otf|png|jpe?g|gif|webp|svg|ico|mp3|mp4|webm)$ { try_files $uri =404; } # SPA-style fallback so unknown navigation routes still load the app shell. location / { try_files $uri $uri/ $sb_shell; } } # One canonical hostname. www.securebit.chat has its own Fly certificate (so # Cloudflare's origin handshake succeeds), but it must not serve the app as a # second address — that splits search ranking and canonical links. Cloudflare # passes the original Host through, so an exact server_name match catches it # here; everything else falls through to the default server above. server { listen 8080; listen [::]:8080; server_name www.securebit.chat; # /sw.js is the one path that must NOT redirect. Visitors who reached www in # the window between its certificate being issued and this redirect shipping # registered the site's service worker against this origin, and that worker # still serves its cached app shell — so those browsers never see the 301, # while their subresource requests do get redirected and are then blocked by # the page's own `script-src 'self'`. A 301 here would make that permanent: # a service worker update fails outright if the script URL redirects. So this # origin answers with a worker that unregisters itself and reloads the tab. # See deploy/www-sw.js. location = /sw.js { alias /etc/nginx/www-sw.js; # add_header in a location REPLACES the server-level set rather than # merging, so the security header has to be restated here. add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; # Re-checked on every navigation instead of being trusted for 24 hours, # which is how quickly a stuck visitor gets rescued. add_header Cache-Control "no-cache, no-store, must-revalidate" always; add_header CDN-Cache-Control "no-store" always; } location / { # The redirect itself must carry HSTS: it is the first response a # first-time www visitor sees, and the preload list covers subdomains. add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; return 301 https://securebit.chat$request_uri; } } }