Skip to content

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”
ValueTLS modeTypical use case
:80Plain HTTP (no TLS)Behind an upstream load balancer that terminates TLS; or local http-only testing
localhost or 127.0.0.1HTTPS via Caddy’s internal CA (self-signed, not trusted by browsers by default)Homelab without a domain; LAN-only access
freesdn.example.comHTTPS 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.

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:

  1. DNS A/AAAA record for the domain points to this server’s public IP.
  2. TCP ports 80 and 443 are reachable from the internet (no firewall blocking).
  3. CADDY_ACME_EMAIL is set to a valid email address (used for expiry notifications).
Terminal window
# In your .env file:
CADDY_SITE_ADDRESS=sdn.example.com
CADDY_ACME_EMAIL=admin@example.com
EDGE_HTTP_PORT=80
EDGE_HTTPS_PORT=443

If Let’s Encrypt fails (“connection refused”), confirm DNS resolution and firewall rules before restarting:

Terminal window
# From a different machine - check DNS
dig +short sdn.example.com
# Check that port 80 is reachable
curl -v http://sdn.example.com/.well-known/acme-challenge/test
Terminal window
CADDY_SITE_ADDRESS=localhost

Caddy 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:

Terminal window
# Get the local CA certificate Caddy generated
docker 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.

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: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()

SPA responses only (scoped to the file_server / try_files block):

  • X-Frame-Options: SAMEORIGIN
  • Content-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.

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:

Terminal window
# In .env.pro (matches the NETWORK_SUBNET for this project)
FORWARDED_ALLOW_IPS=172.29.0.0/16

If you run a hardware or cloud load balancer in front of Caddy, add its IP to this list:

Terminal window
# e.g. an AWS ALB with a private IP in 10.0.0.0/8
FORWARDED_ALLOW_IPS=172.29.0.0/16,10.0.0.0/8

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:

Terminal window
# 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=0

The 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.

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/ws path.
  • Do not buffer WebSocket frames.

A 60-second default idle timeout on an upstream LB will cause the browser to show repeated “Reconnecting…” toasts.

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.