Exposing Home Assistant without opening a port: Cloudflare Tunnel plus Access
|

Exposing Home Assistant without opening a port: Cloudflare Tunnel plus Access

On this page

    There are three common ways to reach Home Assistant from outside your house, and two of them are bad.

    Port forwarding puts your smart home directly on the public internet, where it will be found by automated scanners within hours. Nabu Casa Cloud is genuinely good and worth paying for if you want zero maintenance. The third option, a Cloudflare Tunnel with an Access policy in front of it, is what I run: no inbound ports, no dynamic DNS, no certificate renewal, and an authentication wall that sits in front of Home Assistant rather than inside it.

    This is the setup running on my own subdomain. Here is how it goes together and, more usefully, the parts that are easy to get wrong.

    Ad space, reserved

    Why not just forward port 8123

    Because the moment you do, the login page of your house is reachable by anyone who scans that IP range, which is everyone. Home Assistant has decent brute-force protection, but the correct number of unauthenticated strangers able to reach that page is zero, not few.

    Port forwarding also means your home IP is in DNS, you need dynamic DNS when your ISP rotates it, and you are managing TLS certificates on a box in your living room. A tunnel removes all four problems at once.

    How the tunnel actually works

    The direction of the connection is the whole point. A daemon on your network makes an outbound connection to Cloudflare and holds it open. Requests to your hostname arrive at Cloudflare, travel back down that existing connection, and reach Home Assistant from inside your own network.

    Nothing is listening for inbound connections on your router. There is no port to forward, and nothing to find on a scan. Your firewall stays closed.

    Running cloudflared in Docker

    Create the tunnel in the Cloudflare Zero Trust dashboard under Networks, then Tunnels, and copy the token it gives you. The container needs nothing else.

    # docker-compose.yml
    services:
      cloudflared:
        image: cloudflare/cloudflared:latest
        container_name: cloudflared
        restart: unless-stopped
        command: tunnel --no-autoupdate run
        environment:
          - TUNNEL_TOKEN=${TUNNEL_TOKEN}
        networks:
          - homelab
    
    networks:
      homelab:
        external: true

    Put the token in a .env file beside the compose file rather than inline, and keep that file out of any repository. The token is a credential that grants tunnel access to your network.

    # .env
    TUNNEL_TOKEN=eyJhIjoiXXXXXXXX...
    
    # and in .gitignore
    .env

    Pointing the tunnel at Home Assistant

    In the tunnel configuration, add a public hostname and route it to your Home Assistant service. If both are containers on the same Docker network, use the container name rather than an IP, because container IPs change on restart and hostnames do not.

    Public hostname:  smart.example.com
    Service type:     HTTP
    URL:              homeassistant:8123

    The part everyone hits: trusted proxies

    Do this before you test, or you will spend an evening on it. Home Assistant sees every request as coming from the tunnel container rather than from the real client. Without telling it to trust that proxy, logins fail in confusing ways and every entry in your logbook shows the same internal address.

    # configuration.yaml
    http:
      use_x_forwarded_for: true
      trusted_proxies:
        - 172.18.0.0/16   # your Docker network subnet
        - 127.0.0.1

    Set trusted_proxies to your actual Docker subnet, which you can find with a quick inspect. Do not put 0.0.0.0/0 in there. That tells Home Assistant to believe the forwarded-for header from anyone, which hands an attacker the ability to claim any source address they like.

    docker network inspect homelab --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'

    Restart Home Assistant after changing this. A reload of the YAML is not enough for http settings.

    Putting Access in front of it

    The tunnel solves exposure. It does not solve authentication, and on its own it means your Home Assistant login page is now reachable worldwide, which is roughly where you started. Cloudflare Access is the piece that fixes that.

    Create an Access application for the hostname, then add a policy. The simplest useful one allows a specific list of email addresses, with a one-time PIN sent to that address.

    • Application type: Self-hosted
    • Domain: your Home Assistant hostname
    • Policy action: Allow
    • Include rule: Emails, listing only the addresses that should get in
    • Session duration: 24 hours is a reasonable balance

    Now an unauthenticated visitor never reaches Home Assistant at all. They hit a Cloudflare login screen first. Two independent authentication layers, and the outer one is not code you have to patch.

    The bit that will break your phone app

    Access challenges browsers with an interactive login. The Home Assistant companion app is not a browser and cannot complete that flow, so the app stops connecting the moment you enable Access.

    The fix is a service token: a client ID and secret pair that the app sends as headers, bypassing the interactive login for that one client. Create it under Access, then Service Auth, and add a second policy on the application.

    Policy name:    Companion app
    Action:         Service Auth
    Include rule:   Service Token -> your token name

    Then in the companion app, add the two headers under connection settings.

    CF-Access-Client-Id: <client-id>.access
    CF-Access-Client-Secret: <client-secret>

    A service token is a long-lived credential that grants access without a human present. Give it to the app and nothing else, and rotate it if a phone is lost.

    Checking it is actually closed

    Two checks worth running before you trust it. First, confirm an unauthenticated request never reaches Home Assistant. You want a Cloudflare login page, not a Home Assistant one.

    curl -sS -o /dev/null -w '%{http_code} %{redirect_url}n' https://smart.example.com/

    A redirect to a cloudflareaccess.com address is correct. A 200 that returns Home Assistant markup means the Access policy is not applied to that hostname.

    Second, confirm nothing is listening from outside. From a network that is not yours, a scan of your home IP on 8123 should find nothing at all.

    What this does not protect you from

    Worth being honest about the limits. This design stops unauthenticated strangers reaching Home Assistant, which is the main risk. It does not protect you from a compromised device inside your own network, because that device is already behind the tunnel. It does not protect a service token that leaks. And it makes Cloudflare a dependency: if their edge is down, your remote access is down, though your local network keeps working normally.

    For a smart home that is a fair trade. For anything where an outage costs money, run a second path in.

    Why I run this rather than paying for Cloud

    Nabu Casa Cloud is a good product and it funds Home Assistant development, which matters. If you want this to work without thinking about it, buy it.

    I run the tunnel because the same pattern then serves everything else on the network. Once cloudflared is running and Access is configured, exposing another internal service is a hostname and a policy, not a new subscription. That generalisation is the actual value, and it is the reason this setup is worth the evening it costs.

    Ad space, reserved

    Similar Posts

    Leave a Reply

    Your email address will not be published. Required fields are marked *