
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.
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 nginxNginx 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 nginxSecurity 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 -yCreate 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
EOFCreate 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
EOFNow 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 nginxVerify rules loaded correctly:
sudo systemctl status nginx | grep ModSecurity
# Expected: rules loaded inline/local/remote: 0/917/0Security 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 -yFail2ban 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
EOFStart Fail2ban:
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
sudo fail2ban-client statusYou should see an output like that:
Status
|- Number of jail: 5
`- Jail list: nginx-bad-request, nginx-botsearch, nginx-http-auth, nginx-limit-req, sshdSecurity 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 -yInstall the nginx and Home Assistant relevant parsers:
sudo cscli collections install crowdsecurity/nginx
sudo cscli collections install crowdsecurity/home-assistant 2>/dev/null || trueStart it:
sudo systemctl restart crowdsecWhitelist your own IP so you never accidentally ban yourself:
sudo cscli decisions add --range YOUR_HOME_IP_NETWORK --type whitelistWhitelist your own IP so you never accidentally ban yourself:
sudo cscli alerts list
sudo cscli metricsWithin 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 3022. 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.logThe 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 listSummary
| Layer | Tool | What it does |
|---|---|---|
| WAF | ModSecurity + OWASP CRS | Blocks SQLi, XSS, path traversal at request level |
| Brute force | Fail2ban | Bans IPs after repeated failures from your logs |
| Threat intel | CrowdSec | Community-sourced blocklist, 10k+ known bad IPs |
| Transport | nginx + SSL | Terminates 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.

