Nginx Reverse Proxy for Home Assistant with Security Hardening

Running Home Assistant on a Docker setup at home is great — until you want to access it from outside your local network. The most reliable way to do that securely is through a properly configured nginx reverse proxy with SSL. And since we’re exposing our home automation to the internet, we should do it right: with a WAF, brute-force protection, and community-based threat intelligence.

This article walks through the complete setup I use on my GCP VM running Debian 13 (Trixie) to securely expose Home Assistant — including Portainer ingress — to the internet on port 8123.

Table of Contents
    Add a header to begin generating the table of contents

    Prerequisites

    • Linu server — bare metal or VM
    • Home Assistant with a known internal IP 
    • A domain name points to your server.
      In this article you can find how to set up Free DDNS with Cloudflare
    • Port 8123 open in your firewall/GCP security rules
    • Root or sudo access
    • SSL Certificate:
      Before configuring nginx, you’ll need a valid SSL certificate for your domain. I covered the full process of obtaining a free Let’s Encrypt certificate in a separate article — follow that first, then come back here with your cert files ready at

    Architecture Overview

    The traffic flow looks like this:

    Internet
        │
        ▼
    nginx (bare metal, port 8123)
        │  SSL termination
        │  ModSecurity WAF
        │  Rate limiting
        │  Fail2ban + CrowdSec watching logs
        ▼
    Home Assistant Docker (Local_IP:PORT)
        │
        └── Portainer Ingress (/api/ingress/portainer/)

    A key design decision here: nginx runs on the host, not in a container. This gives Fail2ban and ModSecurity direct access to logs and iptables without Docker networking complexity.

    Install nginx on Bare Metal

    💻
    sudo apt update
    sudo apt install nginx -y
    sudo systemctl enable nginx
    sudo systemctl start nginx

    Nginx Configuration

    Create /etc/nginx/sites-available/home-assistant with the following content. The configuration handles two main concerns: proxying Home Assistant traffic correctly (including WebSockets), and dealing with Portainer’s ingress system which requires special header manipulation.

    📋
    # Header rewrites for Portainer ingress
    map $http_origin $portainer_origin {
        default $http_origin;
        "https://YOUR_DOMAIN:8123" "http://YOUR_LOCAL_IP";
    }
    
    map $http_referer $portainer_referer {
        default $http_referer;
        "https://YOUR_DOMAIN:8123/api/ingress/portainer/" "http://YOUR_PROXY_IP/api/ingress/portainer/";
    }
    
    server {
        listen 8123 ssl;
        server_name YOUR_DOMAIN;
    
        # SSL
        ssl_certificate     /etc/letsencrypt/live/home.diyenjoying.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/home.diyenjoying.com/privkey.pem;
        ssl_session_timeout 1d;
        ssl_session_cache   shared:MozSSL:10m;
        ssl_session_tickets off;
    
        # Security headers
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
    
        # Performance
        proxy_buffering off;
    
        # ModSecurity WAF — uncomment these two lines after completing the ModSecurity section below
        #modsecurity on;
        #modsecurity_rules_file /etc/nginx/modsecurity/main.conf;
    
        # Logging
        access_log /var/log/nginx/ha.access.log;
        error_log  /var/log/nginx/ha.error.log;
    
        # Portainer ingress — requires special header handling
        location /api/ingress/portainer/ {
            proxy_pass         http://YOUR_LOCAL_IP:PORT;
            proxy_redirect     http:// https://;
            proxy_http_version 1.1;
    
            proxy_set_header Host              "YOUR_LOCAL_IP";
            proxy_set_header X-Forwarded-Host  "YOUR_LOCAL_IP";
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto "http";
            proxy_set_header X-Request-Id      $request_id;
            proxy_set_header X-Real-IP         $remote_addr;
            proxy_set_header Upgrade           $http_upgrade;
            proxy_set_header Connection        "upgrade";
            proxy_set_header Origin            $portainer_origin;
            proxy_set_header Referer           $portainer_referer;
        }
    
        # Home Assistant — all other traffic
        location / {
            proxy_pass         http://YOUR_LOCAL_IP:PORT;
            proxy_redirect     http:// https://;
            proxy_http_version 1.1;
    
            proxy_set_header Host              $host;
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Request-Id      $request_id;
            proxy_set_header X-Real-IP         $remote_addr;
    
            # WebSocket support — required for HA real-time updates
            proxy_set_header Upgrade    $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }

    ⚠️ Explanation:

    Don’t forget to replace the following placeholders:

    • YOUR_DOMAIN – your domain name;
    • YOUR_LOCAL_IP – your HA IP address;
    • PORT – your actual HA port.
    • Portainer-related sections are needed if you added it to your Docker setup and want to embed it into HA UI with HACS Ingress integration.

    Why proxy_buffering off? Home Assistant uses long-lived WebSocket connections for real-time state updates. Buffering would delay or break these connections.

    Why the Portainer header manipulation? Portainer’s ingress system rewrites URLs internally. Without explicitly setting Origin and Referer headers to match what Portainer expects internally, the ingress session authentication fails, and you’ll get redirect loops or blank screens.

    Enable the site:

    💻
    sudo ln -s /etc/nginx/sites-available/home-assistant /etc/nginx/sites-enabled/
    sudo nginx -t && systemctl reload nginx

    Security Layer 1 — ModSecurity WAF

    ModSecurity sits between nginx and your backend, inspecting every request for known attack patterns: SQL injection, XSS, path traversal, and more. The OWASP Core Rule Set (CRS) provides 917 rules maintained by the security community.

    💻
    sudo apt install libnginx-mod-http-modsecurity libmodsecurity3t64 modsecurity-crs -y

    Create the ModSecurity config directory and main config file:

    💻
    sudo mkdir -p /etc/nginx/modsecurity
    
    sudo cat > /etc/nginx/modsecurity/modsecurity.conf << 'EOF'
    SecRuleEngine DetectionOnly
    SecRequestBodyAccess On
    SecResponseBodyAccess Off
    SecRequestBodyLimit 13107200
    SecRequestBodyNoFilesLimit 131072
    SecRequestBodyLimitAction Reject
    SecPcreMatchLimit 100000
    SecPcreMatchLimitRecursion 100000
    SecDebugLogLevel 0
    SecAuditEngine RelevantOnly
    SecAuditLogRelevantStatus "^(?:5|4(?!04))"
    SecAuditLogParts ABIJDEFHZ
    SecAuditLogType Serial
    SecAuditLog /var/log/nginx/modsecurity_audit.log
    SecArgumentSeparator &
    SecCookieFormat 0
    SecStatusEngine On
    EOF

    Create the rules loader:

    💻
    sudo cat > /etc/nginx/modsecurity/main.conf << 'EOF'
    Include /etc/nginx/modsecurity/modsecurity.conf
    Include /etc/modsecurity/crs/crs-setup.conf
    Include /usr/share/modsecurity-crs/rules/*.conf
    EOF

    Now go back to your nginx config and uncomment the two ModSecurity lines you left commented earlier:

    📋
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsecurity/main.conf;

    Start in DetectionOnly mode — it logs attacks without blocking them. This lets you identify false positives before switching to blocking mode. After a few days of reviewing /var/log/nginx/modsecurity_audit.log, switch to active blocking:

    💻
    sudo sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/nginx/modsecurity/modsecurity.conf
    sudo nginx -t && systemctl reload nginx

    Verify rules loaded correctly:

    💻
    sudo systemctl status nginx | grep ModSecurity
    # Expected: rules loaded inline/local/remote: 0/917/0

    Security Layer 2 — Fail2ban

    Fail2ban watches your nginx logs and automatically bans IPs that show suspicious behaviour — repeated 401s, bot crawling patterns, and brute-force attempts.

    💻
    sudo apt install fail2ban -y

    Fail2ban watches your nginx logs and automatically bans IPs that show suspicious behaviour — repeated 401s, bot crawling patterns, and brute-force attempts.

    💻
    sudo cat > /etc/fail2ban/jail.local << 'EOF'
    [nginx-http-auth]
    enabled = true
    logpath = /var/log/nginx/ha.error.log
    
    [nginx-limit-req]
    enabled = true
    logpath = /var/log/nginx/ha.error.log
    
    [nginx-botsearch]
    enabled = true
    logpath = /var/log/nginx/ha.access.log
    
    [nginx-bad-request]
    enabled = true
    logpath = /var/log/nginx/ha.access.log
    EOF

    Start Fail2ban:

    💻
    sudo systemctl enable fail2ban
    sudo systemctl start fail2ban
    sudo fail2ban-client status

    You should see an output like that:

    💻
    Status
    |- Number of jail:	5
    `- Jail list:	nginx-bad-request, nginx-botsearch, nginx-http-auth, nginx-limit-req, sshd

    Security Layer 3 — CrowdSec

    CrowdSec is the most powerful addition to the stack. Unlike Fail2ban which only reacts to your own logs, CrowdSec taps into a community threat intelligence network — on install, it immediately downloads and enforces bans on tens of thousands of known malicious IPs worldwide.

    💻
    sudo curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | bash
    sudo apt install crowdsec crowdsec-firewall-bouncer -y

    Install the nginx and Home Assistant relevant parsers:

    💻
    sudo cscli collections install crowdsecurity/nginx
    sudo cscli collections install crowdsecurity/home-assistant 2>/dev/null || true

    Start it:

    💻
    sudo systemctl restart crowdsec

    Whitelist your own IP so you never accidentally ban yourself:

    💻
    sudo cscli decisions add --range YOUR_HOME_IP_NETWORK --type whitelist

    Whitelist your own IP so you never accidentally ban yourself:

    💻
    sudo cscli alerts list
    sudo cscli metrics

    Within minutes you’ll see decisions populated from the community feed (CAPI) covering http:bruteforce, http:scan, ssh:bruteforce, and more — all blocked at the firewall level before they even reach nginx.

    Testing the Setup

    1. Verify the site loads:

    💻
    sudo curl -s -o /dev/null -w "%{http_code}" https://YOUR_DOMAIN:8123
    # Expected: 200 or 302

    2. Verify WebSockets work

    Open Home Assistant in your browser, check that real-time entity updates appear without page refresh. If states don’t update, check the Connection: upgrade headers are passing correctly.

    3. Test ModSecurity detection:

    💻
    curl -s "https://YOUR_DOMAIN:8123/?id=1+UNION+SELECT+1,2,3--"
    tail -5 /var/log/nginx/modsecurity_audit.log

    The SQL injection attempt should appear in the audit log.

    4. Check all security services are running:

    💻
    sudo systemctl status nginx crowdsec fail2ban
    sudo fail2ban-client status
    sudo cscli bouncers list

    Summary

    LayerToolWhat it does
    WAFModSecurity + OWASP CRSBlocks SQLi, XSS, path traversal at request level
    Brute forceFail2banBans IPs after repeated failures from your logs
    Threat intelCrowdSecCommunity-sourced blocklist, 10k+ known bad IPs
    Transportnginx + SSLTerminates TLS, proxies to HA Docker container

    This stack adds meaningful security depth without Cloudflare’s caching complexity — and since nginx runs on bare metal, all three security tools integrate natively with logs and iptables without Docker networking workarounds.

    If your Home Assistant setup also includes other Docker-based tools like Portainer, Code-Server, or CloudBeaver through the ingress system, the same nginx server block pattern applies — just add new location blocks with the appropriate header rewrites for each ingress path.

    Scroll to Top
    DIY Ideas
    Privacy Overview

    This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.