Files
securebit-chat/deploy/nginx.conf
T
lockbitchat 414c27fda6 v6.7.3: faster loading, and pages search engines can read
The bundles carried all thirteen translations at once and a page fetched them a
third time as raw source; each page now loads only its own language. Alongside
that: JavaScript is minified, the eight stylesheets are served as one file, the
QR scanner is fetched after the app is up instead of on every visit, Inter ships
once rather than five copies of the same file, and Font Awesome is subset to the
82 icons this app draws instead of all 2468.

1.85 MB across 43 requests becomes under 700 KB across 33. On mobile the page
starts drawing in 1.6 s instead of 6.3 s and is usable in 4.5 s instead of 11 s.

Pages also carry their text in the HTML now. Everything was drawn by JavaScript
into an empty div, so crawlers saw correct metadata around nothing, and twelve of
the thirteen language pages had never been shown to anyone. The documentation is
published under /docs/ with a new FAQ, and unknown addresses return a real 404.

Separately: the localized shells were served with the year-long immutable cache
header meant for static assets, which pinned anyone who opened /de/ or /ru/ to
that build. The header is fixed and the service worker refreshes what it cached.

Claude-Session: https://claude.ai/code/session_014KjzTXxrhzYoDDWChYQ4u2
2026-09-04 00:41:46 -04:00

178 lines
9.6 KiB
Nginx Configuration File

# 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, honest 404s.
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";
# 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";
~^/sw\.js$ "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";
}
# 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 "";
# 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";
~^/sw\.js$ "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;
}
# 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.
location / {
try_files $uri $uri/ =404;
}
# 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;
}
# 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;
}
}
}