Route B: Reverse Proxy (nginx or Caddy)
The reverse proxy hosts the static build and forwards /api, plus /hub for realtime notifications, to the backend. The browser only ever sees one origin, so CORS still isn't needed. The nginx and Caddy configs are equivalent; pick one and copy it.
Which one to pick
If you already have an nginx gateway, just copy the nginx config as-is. If you're spinning up a fresh box and want to skip manual TLS work, go with Caddy — put your real domain in the site label (not :80) and Caddy automatically obtains and renews a Let's Encrypt certificate. This is also why the repo's Containers & Multi-Replica delivery defaults to Caddy.
nginx
server {
listen 80;
server_name admin.example.com;
# Upload size cap must be >= SmartAdmin:Upload:MaxSizeMb (default 20MB);
# nginx defaults to 1m, so without this large uploads get a 413 instead of the kernel's own error code.
client_max_body_size 32m;
root /var/www/smart; # contents of web/dist
index index.html;
location / {
try_files $uri $uri/ /index.html; # SPA history-mode fallback
}
location /api/ {
proxy_pass http://127.0.0.1:5000; # backend listen address
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 $scheme;
}
# SignalR realtime hub: the WebSocket upgrade must forward Upgrade/Connection explicitly;
# leave this out and realtime notifications silently fall back to 30-second polling.
location /hub/ {
proxy_pass http://127.0.0.1:5000;
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 $scheme;
}
# Health probe: orchestrators and load balancers hit this directly.
# Leave it out and /health falls into try_files above, gets an index.html,
# and the probe keeps returning 200 long after the backend has died.
location /health {
proxy_pass http://127.0.0.1:5000;
access_log off;
}
}Caddy
Use a real domain as the site label and Caddy will auto-issue/renew a certificate; only use :80 locally or when you have no domain. This is the bare-metal install version, following the same approach as the web/Caddyfile inside the Containers & Multi-Replica container.
admin.example.com {
# Upload size cap must be >= SmartAdmin:Upload:MaxSizeMb (default 20MB)
request_body {
max_size 32MB
}
# Must use handle blocks — don't put reverse_proxy and try_files at the same top level:
# Caddy executes by built-in directive order (not the order you wrote them), and try_files
# belongs to the rewrite phase, which runs before reverse_proxy.
# Left flat, a path like /api/... that doesn't exist on disk gets rewritten to /index.html by
# try_files first, so the API request gets back an HTML page — the frontend can't reach the
# backend at all (observed in practice: login just returns empty).
handle /api/* {
reverse_proxy 127.0.0.1:5000
}
# SignalR realtime hub: reverse_proxy handles the WebSocket upgrade on its own
handle /hub/* {
reverse_proxy 127.0.0.1:5000
}
# Probe: hit this directly from your orchestration layer / load balancer
handle /health* {
reverse_proxy 127.0.0.1:5000
}
# Fallback: SPA history-mode routing — anything that doesn't match a static file goes to the SPA
# (so a refresh on a deep link doesn't 404)
handle {
root * /var/www/smart
try_files {path} /index.html
file_server
}
}Both configs still need one more step
Writing X-Forwarded-For in the proxy (nginx) or having reverse_proxy add it automatically (Caddy) isn't enough by itself — the kernel also has to be told to trust it. See "Behind a reverse proxy: getting the kernel the real client IP" below. Without it, the backend always sees the proxy's single IP: every user shares the same rate-limit bucket (one person hammering the login endpoint can lock everyone else out), per-IP brute-force protection is neutralized, and the IP column in login logs is just the proxy's address.
Behind a reverse proxy: getting the kernel the real client IP
Behind any reverse proxy (nginx / Caddy / Traefik / a k8s ingress), this block is required:
{
"SmartAdmin": {
"Api": {
"ForwardedHeaders": {
"Enabled": true,
"KnownProxies": [ "10.0.0.8" ],
"KnownNetworks": [ "172.16.0.0/12" ]
}
}
}
}- Off by default. Turning it on when you're not actually behind a proxy lets anyone forge their own IP.
- Turning it on requires declaring trusted sources (
KnownProxiesfor specific IPs,KnownNetworksfor CIDR ranges — under container orchestration the proxy's IP isn't fixed, so a range is more practical). Give neither, and startup fails outright. This isn't pedantry: blindly trustingX-Forwarded-Foris worse than not parsing it at all — an attacker can forge a different IP on every request to open unlimited rate-limit buckets (completely bypassing rate limiting), and pin brute-force failures on someone else. - What's trusted is the source address, so: anyone who can connect directly to the backend port can forge an IP. Once you're behind a reverse proxy, don't expose the backend port — that's why the debug port in the compose file is bound to
127.0.0.1only.