FamilyNido — a self-hosted PWA for a single household: shared calendar, chores, meals, school agenda, health records and a family wall. One instance per family, deployable with `docker compose` on any home server. Stack: .NET 10 (ASP.NET Core Minimal APIs) + EF Core 10 + PostgreSQL 16 on the backend, Angular 21 (standalone, signals, zoneless) + Tailwind CSS v4 on the frontend, SignalR for realtime, optional OIDC alongside local credentials, integration via a versioned `/api/v1/**` public API. See README.md for the module overview and how to deploy.
135 lines
6 KiB
Text
135 lines
6 KiB
Text
server {
|
||
listen 8080;
|
||
listen [::]:8080;
|
||
server_name _;
|
||
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
# We sit behind Traefik on a different scheme/port. nginx defaults to
|
||
# `absolute_redirect on`, which would rewrite a `return 302 /es/;` to an
|
||
# absolute `http://<server_name>:8080/es/` — and the browser would then
|
||
# follow that to the internal port, bypassing the proxy.
|
||
# Sending only the path lets the browser resolve it against the public
|
||
# URL it actually used (https://familia.example.com/...).
|
||
absolute_redirect off;
|
||
|
||
# Bigger proxy buffers for the API responses. The /signin-oidc 302 returns
|
||
# a session cookie chunked across `familynido.session*` cookies, easily
|
||
# 6–10 KB of headers. With the default 4–8 KB buffer nginx would abort
|
||
# with `upstream sent too big header` and surface a 502.
|
||
proxy_buffer_size 16k;
|
||
proxy_buffers 8 16k;
|
||
proxy_busy_buffers_size 32k;
|
||
|
||
# Bigger *request* header buffers. The OIDC callback receives a long
|
||
# `?state=...` plus the browser's Cookie header (session + correlation
|
||
# cookies + sometimes orphan nonces from prior failed attempts). The
|
||
# default 4×8K trips the moment any of those grow, returning a stark
|
||
# 400 "Request Header Or Cookie Too Large" before the request even
|
||
# reaches the backend.
|
||
large_client_header_buffers 8 32k;
|
||
|
||
# Allow image uploads up to ~12 MB. The API enforces its own 10 MB cap
|
||
# (FilesOptions.MaxImageBytes); we pad slightly so a borderline file gets
|
||
# nginx out of the way and surfaces the API's clean validation error
|
||
# instead of a blunt 413 with no body. iOS HEIC photos routinely sit
|
||
# in the 3–8 MB range and would otherwise fail silently.
|
||
client_max_body_size 12m;
|
||
|
||
# Gzip text assets; PWA manifest explicitly included.
|
||
gzip on;
|
||
gzip_comp_level 6;
|
||
gzip_min_length 1024;
|
||
gzip_types text/plain text/css application/javascript application/json
|
||
application/manifest+json image/svg+xml text/xml;
|
||
|
||
# Aggressive caching for hashed bundle files (Angular outputHashing=all).
|
||
location ~* \.(?:js|css|woff2?|ttf|otf|eot|ico|svg|png|jpg|jpeg|gif|webp|avif)$ {
|
||
expires 1y;
|
||
add_header Cache-Control "public, immutable";
|
||
try_files $uri =404;
|
||
}
|
||
|
||
# Service worker registration script and config must never be cached.
|
||
location ~* (ngsw-worker\.js|ngsw\.json|safety-worker\.js|worker-basic\.min\.js|manifest\.webmanifest)$ {
|
||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||
try_files $uri =404;
|
||
}
|
||
|
||
# Pass /api through to the backend container (name "api" in compose).
|
||
location /api/ {
|
||
proxy_pass http://api: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;
|
||
# Preserve the scheme Traefik announced to the outside world
|
||
# (https) instead of overwriting it with nginx's own $scheme (http).
|
||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||
proxy_read_timeout 60s;
|
||
}
|
||
|
||
# OIDC callback paths handled by the backend. Need the same forwarded
|
||
# headers as /api/ so the OIDC middleware validates the callback against
|
||
# the public host/scheme and the AuthenticationProperties round-trip works.
|
||
location = /signin-oidc {
|
||
proxy_pass http://api: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 $http_x_forwarded_proto;
|
||
}
|
||
location = /signout-callback-oidc {
|
||
proxy_pass http://api: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 $http_x_forwarded_proto;
|
||
}
|
||
|
||
location = /healthz {
|
||
access_log off;
|
||
return 200 "ok\n";
|
||
add_header Content-Type text/plain;
|
||
}
|
||
|
||
# ── Locale routing (i18n subpath layout) ────────────────────────────────
|
||
# Build emits two bundles: dist/.../browser/es/ and dist/.../browser/en/.
|
||
# The Dockerfile copies the entire `browser/` tree to /usr/share/nginx/html
|
||
# so the container ends up with /usr/share/nginx/html/{es,en}/index.html.
|
||
#
|
||
# Root visit: pick es or en from Accept-Language and redirect. Anyone
|
||
# bookmarked at /es/... or /en/... hits the SPA below directly.
|
||
|
||
location = / {
|
||
# Negotiate locale from Accept-Language. Defaults to es-ES (the
|
||
# source locale) for any browser that doesn't explicitly prefer
|
||
# English. Add more `if` branches as locales grow.
|
||
if ($http_accept_language ~* "^en") {
|
||
return 302 /en/;
|
||
}
|
||
return 302 /es/;
|
||
}
|
||
|
||
# SPA fallback per locale: any unknown path under /es/ or /en/ falls
|
||
# through to that bundle's index.html so the Angular router takes over.
|
||
location /es/ {
|
||
try_files $uri $uri/ /es/index.html;
|
||
}
|
||
location /en/ {
|
||
try_files $uri $uri/ /en/index.html;
|
||
}
|
||
|
||
# Catch-all for unprefixed paths. Anything we didn't match above (and
|
||
# that isn't /api/ or one of the OIDC callbacks) is a stray client
|
||
# request — typically a stale `returnUrl` like `/home` from before the
|
||
# /es//en/ split. Redirect it through to the Spanish bundle so the
|
||
# SPA can take over instead of nginx returning its default 404.
|
||
location / {
|
||
if ($http_accept_language ~* "^en") { return 302 /en$request_uri; }
|
||
return 302 /es$request_uri;
|
||
}
|
||
}
|