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.