2026-06-15 16:23:14 -04:00
|
|
|
# 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,
|
2026-09-04 00:41:46 -04:00
|
|
|
# long-immutable cache for hashed/static assets, security headers, honest 404s.
|
2026-06-15 16:23:14 -04:00
|
|
|
|
|
|
|
|
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;
|
2026-07-22 00:41:58 -04:00
|
|
|
gzip_proxied any;
|
|
|
|
|
gzip_comp_level 6;
|
2026-06-15 16:23:14 -04:00
|
|
|
gzip_min_length 256;
|
2026-07-22 00:41:58 -04:00
|
|
|
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;
|
2026-06-15 16:23:14 -04:00
|
|
|
|
|
|
|
|
# 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";
|
2026-09-04 00:41:46 -04:00
|
|
|
# Quoted because nginx reads a bare { as the start of a block: an unquoted
|
|
|
|
|
# regex with a repetition count fails the config at boot, not at request time.
|
|
|
|
|
# Both anchors matter. These patterns used to be ~^/index\.html$ and ~^/$,
|
|
|
|
|
# which match the English shell and nothing else — so /de/, /ru/index.html and
|
|
|
|
|
# every other localized page fell through to the one-year immutable default
|
|
|
|
|
# below. A visitor who opened a localized page kept that exact HTML, and the
|
|
|
|
|
# ?v= stamps inside it, for a year: no release could reach them.
|
|
|
|
|
"~^/([a-z]{2}/)?(index\.html)?$" "no-cache, no-store, must-revalidate";
|
|
|
|
|
"~^/([a-z]{2}/)?manifest\.json$" "no-cache, no-store, must-revalidate";
|
2026-06-15 16:23:14 -04:00
|
|
|
~^/sw\.js$ "no-cache, no-store, must-revalidate";
|
|
|
|
|
~^/meta\.json$ "no-cache, no-store, must-revalidate";
|
2026-07-22 18:10:17 -04:00
|
|
|
# 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";
|
2026-07-22 00:41:58 -04:00
|
|
|
# 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";
|
2026-08-29 12:46:15 -04:00
|
|
|
# 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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 00:41:58 -04:00
|
|
|
# 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 "";
|
2026-09-04 00:41:46 -04:00
|
|
|
# Same anchoring fix as above: Cloudflare was edge-caching the localized
|
|
|
|
|
# shells for a year too.
|
|
|
|
|
"~^/([a-z]{2}/)?(index\.html)?$" "no-store";
|
|
|
|
|
"~^/([a-z]{2}/)?manifest\.json$" "no-store";
|
2026-07-22 00:41:58 -04:00
|
|
|
~^/sw\.js$ "no-store";
|
|
|
|
|
~^/meta\.json$ "no-store";
|
2026-07-22 18:10:17 -04:00
|
|
|
~^/config/ice-servers\.js$ "no-store";
|
2026-07-22 00:41:58 -04:00
|
|
|
~^/dist/ "no-store";
|
2026-08-29 12:46:15 -04:00
|
|
|
~^/src/i18n/ "no-store";
|
2026-06-15 16:23:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
server {
|
2026-09-01 01:04:11 -04:00
|
|
|
listen 8080 default_server;
|
|
|
|
|
listen [::]:8080 default_server;
|
2026-06-15 16:23:14 -04:00
|
|
|
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;
|
2026-06-18 16:48:29 -04:00
|
|
|
# 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;
|
2026-07-23 12:43:06 -04:00
|
|
|
# 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;
|
2026-06-15 16:23:14 -04:00
|
|
|
add_header Cache-Control $sb_cache always;
|
2026-07-22 00:41:58 -04:00
|
|
|
# Edge-cache directive for Cloudflare/CDNs (empty value → header is omitted).
|
|
|
|
|
add_header CDN-Cache-Control $sb_cdn_cache always;
|
2026-06-15 16:23:14 -04:00
|
|
|
add_header Service-Worker-Allowed "/" always;
|
|
|
|
|
|
2026-06-15 16:30:39 -04:00
|
|
|
# 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).
|
2026-08-29 12:46:15 -04:00
|
|
|
location ~* \.(js|mjs|jsx|css|json|map|xml|txt|woff2?|ttf|otf|png|jpe?g|gif|webp|svg|ico|mp3|mp4|webm)$ {
|
2026-06-15 16:30:39 -04:00
|
|
|
try_files $uri =404;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 00:41:46 -04:00
|
|
|
# The app has no client-side routes: every real address is either a file, the
|
|
|
|
|
# root, or a locale directory, and $uri/ resolves /de/ through the index
|
|
|
|
|
# directive. So an unknown path is a typo or a scanner probe, not a route.
|
|
|
|
|
# Answering those with the app shell under 200 OK made every one of them an
|
|
|
|
|
# indexable "page" — an infinite URL space that Search Console reports as soft
|
|
|
|
|
# 404s and duplicates, spending crawl budget on addresses that do not exist.
|
2026-06-15 16:23:14 -04:00
|
|
|
location / {
|
2026-09-04 00:41:46 -04:00
|
|
|
try_files $uri $uri/ =404;
|
2026-06-15 16:23:14 -04:00
|
|
|
}
|
2026-09-04 00:41:46 -04:00
|
|
|
|
|
|
|
|
# A real page for a real 404, rather than nginx's own. Served from the root, so
|
|
|
|
|
# the block above resolves it as an ordinary file; the 404 status is preserved.
|
|
|
|
|
error_page 404 /404.html;
|
2026-06-15 16:23:14 -04:00
|
|
|
}
|
2026-09-01 01:04:11 -04:00
|
|
|
|
|
|
|
|
# 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 16:23:14 -04:00
|
|
|
}
|