Networking and TLS
FreeSDN’s default edge is Caddy. It terminates TLS, serves the React SPA, reverse-proxies /api (HTTP, WebSocket, and camera/SSE streams unbuffered), and sets security headers. Only the edge publishes host ports; all internal services (PostgreSQL, Valkey, the API) are on an isolated Docker network.
One variable controls TLS mode: CADDY_SITE_ADDRESS
Section titled “One variable controls TLS mode: CADDY_SITE_ADDRESS”| Value | TLS mode | Typical use case |
|---|---|---|
:80 | Plain HTTP (no TLS) | Behind an upstream load balancer that terminates TLS; or local http-only testing |
localhost or 127.0.0.1 | HTTPS via Caddy’s internal CA (self-signed, not trusted by browsers by default) | Homelab without a domain; LAN-only access |
freesdn.example.com | HTTPS via Let’s Encrypt (auto-obtained and renewed) | Public-facing production deployment |
That’s it. No cert scripts, no certbot, no template switching. Caddy handles certificate issuance, renewal, and OCSP stapling automatically. Certificates are persisted in the caddy_data volume so they survive container restarts.
Let’s Encrypt requirements
Section titled “Let’s Encrypt requirements”When CADDY_SITE_ADDRESS is set to a domain name, Caddy uses the ACME HTTP-01 challenge. The following must be true before you start the stack:
- DNS A/AAAA record for the domain points to this server’s public IP.
- TCP ports 80 and 443 are reachable from the internet (no firewall blocking).
CADDY_ACME_EMAILis set to a valid email address (used for expiry notifications).
# In your .env file:CADDY_SITE_ADDRESS=sdn.example.comCADDY_ACME_EMAIL=admin@example.comEDGE_HTTP_PORT=80EDGE_HTTPS_PORT=443If Let’s Encrypt fails (“connection refused”), confirm DNS resolution and firewall rules before restarting:
# From a different machine - check DNSdig +short sdn.example.com
# Check that port 80 is reachablecurl -v http://sdn.example.com/.well-known/acme-challenge/testInternal CA (homelab without a domain)
Section titled “Internal CA (homelab without a domain)”CADDY_SITE_ADDRESS=localhostCaddy generates a local root CA and issues a certificate for localhost. Browsers do not trust this CA by default - you will see a certificate warning. You can suppress it by importing Caddy’s local CA into your OS or browser trust store:
# Get the local CA certificate Caddy generateddocker compose --env-file .env.lite exec edge \ cat /data/caddy/pki/authorities/local/root.crt > caddy-local-ca.crt# Then import caddy-local-ca.crt into your browser or OS trust store.For a production homelab with internal DNS (split-horizon), point a real domain at your LAN IP and let Caddy obtain a Let’s Encrypt certificate normally. Port 80 just needs to be reachable for the ACME challenge.
Security headers
Section titled “Security headers”The Caddy edge applies security headers in two scopes.
All responses (site-level block - including /api and the SPA):
Strict-Transport-Security: max-age=63072000; includeSubDomains(HSTS)X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
SPA responses only (scoped to the file_server / try_files block):
X-Frame-Options: SAMEORIGINContent-Security-Policy: default-src 'self'; …
X-Frame-Options and Content-Security-Policy are deliberately not set at the site level. API responses carry the backend middleware’s own stricter versions of those headers (X-Frame-Options: DENY, frame-ancestors 'none'), and a site-level Caddy override would weaken them.
Caddy defaults to TLS 1.2 and 1.3 with strong cipher suites (ECDHE + AES-GCM / ChaCha20-Poly1305). No custom TLS policy is configured in the Caddyfile; cipher selection and session ticket behaviour follow Caddy’s built-in defaults (session tickets enabled for performance). The nginx escape-hatch profile (frontend/nginx/ssl.conf) does explicitly set ssl_session_tickets off; if you switch to that edge.
Trusted proxy: FORWARDED_ALLOW_IPS
Section titled “Trusted proxy: FORWARDED_ALLOW_IPS”The API uses X-Forwarded-For to determine the real client IP for rate limiting and audit logging. It only trusts this header from addresses listed in FORWARDED_ALLOW_IPS.
Geo-blocking (country-level filtering) is not enforced at the FreeSDN API layer. It is a capability pushed to upstream firewall devices via the OPNsense and pfSense adapters.
Set this to the Docker compose network subnet so the Caddy (or nginx) edge container is trusted:
# In .env.pro (matches the NETWORK_SUBNET for this project)FORWARDED_ALLOW_IPS=172.29.0.0/16If you run a hardware or cloud load balancer in front of Caddy, add its IP to this list:
# e.g. an AWS ALB with a private IP in 10.0.0.0/8FORWARDED_ALLOW_IPS=172.29.0.0/16,10.0.0.0/8nginx escape hatch
Section titled “nginx escape hatch”For environments that standardize on nginx, or where TLS is terminated at an upstream load balancer, FreeSDN includes an nginx edge profile. Enable it instead of Caddy:
# In your .env file:COMPOSE_PROFILES=nginx,io-worker,monitoring
# Bring up the stack and suppress the Caddy service:docker compose --env-file .env.pro up -d --scale edge=0The nginx profile starts the edge-nginx service as the edge. In this mode, TLS termination happens outside FreeSDN (at your upstream LB or nginx config), and CADDY_SITE_ADDRESS has no effect.
WebSocket proxying
Section titled “WebSocket proxying”The Caddy config uses a single unbuffered reverse proxy for all /api* paths (flush_interval -1), which covers WebSocket connections, camera MSE/MJPEG streams, and SSE streams. No explicit idle timeout is configured; Caddy relies on its built-in defaults. If you insert your own load balancer in front of Caddy, ensure it is configured for WebSocket pass-through:
- Set the idle / read timeout to at least 3600 seconds on the
/api/v1/wspath. - Do not buffer WebSocket frames.
A 60-second default idle timeout on an upstream LB will cause the browser to show repeated “Reconnecting…” toasts.