Skip to main content

Hardened Nginx as reverse proxy for Home Assistant

·5 mins

Some time ago I stood at the question of VPN vs. Reverse Proxy for my Home Assistant .

On one hand, a VPN (like ZeroTier or WireGuard) is the “safe bet.” It keeps your instance invisible to the public internet, but it’s cumbersome. The user experience friction is real. Requiring every family member to toggle a VPN on their phone just to turn on the lights or check the garage door is a dealbreaker. Also, no notifications work when the VPN is not connected - and i have a lot of em. It kills the “smart” in the smart home.

On the other hand, exposing Home Assistant via a reverse proxy offers a seamless, native experience, but it comes with security anxiety. Many solve this by slapping a Cloudflare Tunnel in front of their instance.

I didn’t want my ability to open my garage door to depend on the uptime of us-east-1 or a random Cloudflare routing issue. I wanted full independence as a selfhosted solution. I chose to build a “Fortress Nginx”—a hardened reverse proxy running locally, stripped of reliance on third-party clouds, but having modern standards.

Below is the configuration I am currently running. It sits behind an OPNsense firewall equipped with CrowdSec and runs on a separate VLAN. Your network may look different, but the logic is the same.

The Configuration #

This setup prioritizes performance and security. It uses HTTP/3 (QUIC) and BBR for low latency, Brotli for better compression, and TLS 1.3 to cut out legacy vulnerabilities.

Enable BBR on Linux: #

echo "net.core.default_qdisc=fq" >> /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf

sysctl -p

sysctl net.ipv4.tcp_congestion_control

Note: The GeoIP block is commented out in the snippet below, but the logic is ready to go if you have the database loaded.

We’re enforcing TLS.1.3 as the only protocol, using modern X25519 as the only ECDH curve, little session cache and turning off session tickets to enforce perfect forward secrecy.

snippets/options-ssl.conf #

1ssl_session_cache shared:le_nginx_SSL:1m;
2ssl_session_timeout 1440m;
3ssl_session_tickets off;
4
5ssl_protocols TLSv1.3;
6
7ssl_prefer_server_ciphers off;
8ssl_ecdh_curve X25519:prime256v1:secp384r1;

sites-enabled/homeassistant.conf #

  1upstream ha_backend {
  2    server your_ha_ip:8123;
  3    keepalive 32; # Keep 32 open connections to HA
  4}
  5
  6map $http_upgrade $connection_upgrade {
  7    default upgrade;
  8    ''      "";
  9}
 10
 11#geoip2 /etc/nginx/geo/GeoLite2-Country.mmdb {
 12#    auto_reload 5m;
 13#    $geoip2_data_country_code country iso_code;
 14#}
 15
 16# Basic User-Agent filtering
 17map $http_user_agent $block_ua {
 18    default 1;
 19    "~*Home Assistant" 0;
 20    "~*Chrome" 0;
 21    "~*Safari" 0;
 22    "~*Firefox" 0;
 23    "~*Edge" 0;
 24}
 25
 26# Allow specific countries only
 27map $geoip2_data_country_code $block_country {
 28    default 1;
 29    PL 0; DE 0; CZ 0; SE 0;
 30}
 31
 32# Whitelist local networks, block the rest based on country
 33map $remote_addr $block_all {
 34    "~^127\."             0;
 35    "~^::1"               0;
 36    "~^192\.168\."        0;
 37    "~^10\."              0;
 38    "~^172\.(1[6-9]|2[0-9]|3[0-1])\."  0;
 39    default               $block_country;
 40}
 41
 42limit_req_zone $binary_remote_addr zone=ha_ratelimit:10m rate=25r/s;
 43
 44# Redirect HTTP to HTTPS
 45server {
 46    server_name homeassistant.example.com;
 47    listen 80;
 48    return 301 https://$host$request_uri;
 49}
 50
 51server {
 52    server_name homeassistant.example.com;
 53
 54    # HTTP/3 + TCP Fast Open
 55    listen 443 ssl fastopen=256;
 56    listen 443 quic reuseport;
 57    http2 on;
 58
 59    ssl_early_data on;
 60
 61    # Certificates
 62    ssl_certificate /etc/letsencrypt/live/homeassistant.example.com/fullchain.pem;
 63    ssl_certificate_key /etc/letsencrypt/live/homeassistant.example.com/privkey.pem;
 64
 65    include /etc/nginx/snippets/options-ssl.conf;
 66
 67    # SECURITY HEADERS
 68    server_tokens off;
 69    more_clear_headers Server;
 70    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
 71    add_header Alt-Svc 'h3=":443"; ma=86400' always;
 72
 73    # BROTLI
 74    brotli on;
 75    brotli_comp_level 6;
 76    brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
 77
 78    # BLOCKING LOGIC
 79    if ($block_all) { return 666; }
 80    if ($block_ua) { return 444; }
 81    if ($host != $server_name) { return 403; }
 82    if ($http_user_agent = "") { return 444; }
 83
 84    location / {
 85        limit_req zone=ha_ratelimit burst=300 nodelay;
 86
 87        proxy_pass http://ha_backend;
 88
 89        # Keepalive + WebSocket Magic
 90        proxy_http_version 1.1;
 91        proxy_set_header Upgrade $http_upgrade;
 92        proxy_set_header Connection $connection_upgrade;
 93
 94        proxy_set_header Host $host;
 95        proxy_set_header X-Real-IP $remote_addr;
 96        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 97        proxy_set_header X-Forwarded-Proto $scheme;
 98        proxy_redirect http:// https://;
 99
100        # Buffering - disabled for responsiveness
101        proxy_buffering off;
102
103        # Timeouts
104        proxy_read_timeout 3600s;
105        proxy_send_timeout 3600s;
106
107        # Early Data (0-RTT) forward to backend
108        proxy_set_header Early-Data $ssl_early_data;
109    }
110
111    # HLS / CAMERA STREAMING
112    location /api/hls/ {
113        proxy_pass http://ha_backend;
114
115        proxy_http_version 1.1;
116        proxy_set_header Connection ""; # Force keepalive (stream does not do upgrade)
117
118        proxy_buffering off;
119        proxy_request_buffering off;
120
121        proxy_read_timeout 3600s;
122        proxy_send_timeout 3600s;
123    }
124}

What’s the outcome? #

Security - With TLS 1.3 only, we eliminate weaker ciphers entirely. The custom blocking logic (returning non-standard codes like 666 or 444) combined with Geo-blocking instantly drops noise from botnets before it even touches the app.

Performance - Enabling HTTP/3 (QUIC) and TCP BBR - improves connection stability on mobile networks (e.g., switching from Wi-Fi to LTE). Unlike traditional congestion control algorithms (like CUBIC) that throttle speed the moment they detect packet loss, BBR intelligently models the network pipe. This is great for accessing Home Assistant over mobile networks, ensuring the dashboard and video streams from cameras loads instantly even on a spotty 4G signal. Brotli compression makes the data transferred is minimized.

Independence - Access to my home is direct, so there is no middleman. If a major cloud provider has an outage, my smart home remains accessible.

This approach requires more initial effort than installing a VPN app, but the result is a “set and forget” gateway that is both invisible to unauthorized traffic and instantly available to authorized users.

Also keep in mind that enabling 2fa for your homeassistant account is not a requirement, but it’s a very good idea to have it enabled.