Browser VSCode docker image used to manage docker host with addon

Status: CURRENT — SECURITY SENSITIVE
Last reviewed: 31 August 2026
Applies to: LinuxServer.io code-server, Docker Engine, Docker Compose

This setup deliberately gives a browser-accessible code-server instance control of the Docker daemon on the host. That is extremely powerful and should be treated as effectively giving the application administrator-level access to the Docker host.

What this does

This setup runs VS Code in the browser using LinuxServer.io’s code-server image, then adds the LinuxServer universal-docker mod so the Docker CLI and Docker Compose are available inside the container.

I also mount my host’s Docker Compose and Docker data folders into the code-server workspace. This lets me edit Compose files, configs and other Docker-related files from the browser, then use the built-in terminal to run normal Docker commands against the host.

The important bits are:

  • DOCKER_MODS=linuxserver/mods:universal-docker installs the Docker CLI, Buildx and Compose plugin inside code-server.
  • The Docker socket is mounted into the container so those Docker commands control the host Docker daemon.
  • Host folders containing Compose files and container data can be mounted into the workspace for easy editing.

⚠️ Security warning — read this before copying the Compose file

Mounting /var/run/docker.sock into a container is a major security decision. Docker’s own documentation warns that only trusted users should be able to control the Docker daemon because Docker can mount arbitrary host directories into containers. In practical terms, a process with unrestricted access to the Docker socket can usually gain root-level control of the host.

That means if this code-server instance is compromised, you should assume the Docker host is compromised as well.

Do not expose this directly to the public internet unless you fully understand and accept that risk.

How I recommend accessing it

When I originally wrote this guide I used Traefik and Authentik in front of code-server. That’s still useful, but for an application with this much control I now recommend keeping it private to your LAN/Tailscale network wherever possible.

My preferred setup is:

  1. Do not publish the code-server hostname publicly.
  2. Use Tailscale when accessing your home network remotely.
  3. Use Tailscale Split DNS to resolve your private domain to your internal reverse proxy only while connected to your tailnet.
  4. Keep Authentik in front of code-server as another authentication layer. MFA is strongly recommended.
  5. Optionally keep code-server’s own hashed password enabled as a second independent authentication layer.

I have a separate guide for the private DNS side here: Tailscale Split DNS by Domain for Secure Home Server Access.

Tailscale’s current documentation calls this a restricted nameserver, or Split DNS. Queries for only your chosen private domain are sent to your own DNS server while other DNS continues normally.

Start with the LinuxServer.io code-server image

LinuxServer.io’s current base Compose example looks like this:

services:
  code-server:
    image: lscr.io/linuxserver/code-server:latest
    container_name: code-server
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
      - PASSWORD=password # optional
      - HASHED_PASSWORD= # optional
      - SUDO_PASSWORD=password # optional
      - SUDO_PASSWORD_HASH= # optional
      - PROXY_DOMAIN=code-server.my.domain # optional
      - DEFAULT_WORKSPACE=/config/workspace # optional
      - PWA_APPNAME=code-server # optional
    volumes:
      - /path/to/code-server/config:/config
    ports:
      - 8443:8443
    restart: unless-stopped


Check the current LinuxServer.io code-server documentation before deploying, as image options can change over time.

Add Docker host control

LinuxServer provides the universal-docker Docker Mod, which adds the Docker CLI, Buildx and Docker Compose plugin to the container.

DOCKER_MODS=linuxserver/mods:universal-docker


The Docker socket then needs to be mounted:

/var/run/docker.sock:/var/run/docker.sock:ro


Important: The :ro on the bind mount does not make the Docker API read-only. It prevents the container from replacing or modifying the socket file itself, but commands sent through that socket can still create, stop, remove and otherwise control containers if the Docker API permits them.

If you only need to view Docker information rather than manage the host, LinuxServer also publishes a Docker Socket Proxy that can restrict which API endpoints are available. That is safer, but it defeats the purpose of this particular setup if you want full Docker/Compose management.

My folder layout

On my Docker host I keep:

/home/richay/docker-compose/


for Compose files and small hand-managed configuration files, and:

/home/richay/docker/


for persistent container data.

Mounting those into the code-server workspace gives me quick browser access to the files I normally need to edit.

Recommended Compose — private reverse proxy + Authentik

This is the version closest to what I use. There is no published Docker port; Traefik reaches code-server over the external proxy network.

I recommend making the hostname available only through your LAN/private DNS or Tailscale Split DNS rather than creating a public DNS record for it.

services:
  code-server:
    image: lscr.io/linuxserver/code-server:latest
    container_name: code-server
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
      - DEFAULT_WORKSPACE=/config/workspace
      - PWA_APPNAME=code-server
      - DOCKER_MODS=linuxserver/mods:universal-docker

      # Optional but recommended as a second auth layer.
      # Generate a strong hash rather than using PASSWORD in plain text.
      # - HASHED_PASSWORD=$2a$...

    volumes:
      - /home/richay/docker/code:/config
      - /home/richay/docker-compose:/config/workspace/docker-compose
      - /home/richay/docker:/config/workspace/docker
      - /var/run/docker.sock:/var/run/docker.sock:ro

    restart: unless-stopped

    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.code-secure.entrypoints=https"
      - "traefik.http.routers.code-secure.rule=Host(`code-server.richay.au`)"
      - "traefik.http.routers.code-secure.tls=true"
      - "traefik.http.routers.code-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.code-secure.service=code"
      - "traefik.http.services.code.loadbalancer.server.port=8443"
      - "traefik.http.routers.code-secure.middlewares=authentik@file"

    networks:
      - proxy

networks:
  proxy:
    external: true


The port is deliberately not published with ports:. That stops code-server bypassing Traefik/Authentik through something like http://host-ip:8443.

With Split DNS, code-server.richay.au can resolve to the internal reverse proxy while you’re at home or connected to Tailscale, without exposing that hostname/service publicly.

If you don’t use a reverse proxy

You can still run code-server directly, but I would keep it on a trusted LAN/VPN rather than forwarding port 8443 from the internet.

Use a hashed password and bind the port to the Docker host’s private LAN address rather than every interface. Replace 192.168.1.10 with the actual LAN IP of your Docker host:

services:
  code-server:
    image: lscr.io/linuxserver/code-server:latest
    container_name: code-server
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
      - HASHED_PASSWORD=$2a$...
      - DEFAULT_WORKSPACE=/config/workspace
      - PWA_APPNAME=code-server
      - DOCKER_MODS=linuxserver/mods:universal-docker

    volumes:
      - /path/to/code-server/config:/config
      - /path/to/docker-compose:/config/workspace/docker-compose
      - /path/to/docker:/config/workspace/docker
      - /var/run/docker.sock:/var/run/docker.sock:ro

    ports:
      - 192.168.1.10:8443:8443

    restart: unless-stopped


You would then access it at:

http://192.168.1.10:8443


Again: do not port-forward 8443 from your router to this container. If you need it away from home, use Tailscale or another trusted VPN.

Test Docker access

Open a terminal inside code-server and run:

docker ps


If the Docker Mod and socket are working, you should see the containers running on the host.

Docker Compose should also be available:

docker compose version


From there you can open your mounted Compose folder in VS Code, edit a stack and use the terminal as you normally would.

What happens if code-server is compromised?

This is worth spelling out because mounting the Docker socket can look harmless if you’ve never dealt with Docker’s security model.

An attacker who gains usable access to the Docker daemon could potentially create a container that mounts the host filesystem, access other containers and their volumes, read Docker secrets/configuration, start privileged workloads or otherwise take control of the machine.

So the security boundary is not:

attacker → code-server container → stopped by Docker container isolation


It is much closer to:

attacker → code-server → Docker socket → host control


That’s why I now recommend keeping this service private behind Tailscale/LAN access even if you also use Authentik.

Optional: Docker Socket Proxy for read-only use

If your goal is only to view containers rather than start/stop/deploy them, don’t give code-server unrestricted access to the raw Docker socket.

LinuxServer’s Docker Socket Proxy can expose only selected Docker API endpoints. For example, a proxy can allow container listing while blocking API POST requests.

For full Docker Compose management this isn’t particularly useful because the permissions required to create containers and mount host paths are the same powerful permissions we’re trying to protect. But for monitoring/read-only workflows it’s a much safer design.

References

Once it’s locked down properly, having direct browser access to your Compose files and the Docker CLI is bloody convenient 🙂

Tailscale split dns by domain for secure home server access

Status: CURRENT
Last reviewed: 31 August 2026
Applies to: Tailscale Split DNS, subnet routers, AdGuard Home and a private reverse proxy

This setup lets you keep selected services publicly reachable while resolving your private services to internal IP addresses only when you’re at home or connected to Tailscale. Basically: public where I actually want public, private everywhere else. Much nicer than throwing the whole bloody homelab onto the internet.

What this setup does

I use the same domain for both public and private services. Public services can still be reachable normally, while private subdomains resolve through my own DNS server and point at my internal reverse proxy when I’m connected to Tailscale. One domain, two completely different paths depending on where I am. Neat little trick 🙂

For this example:

  • Domain: richay.au
  • AdGuard Home: 10.10.10.10 — local DNS / ad blocker
  • Traefik v3: 10.10.10.20 — internal reverse proxy
  • AdGuard DNS rewrite: *.richay.au → 10.10.10.20
  • Cloudflare Tunnel: only for services I deliberately expose publicly, such as this blog
  • Tailscale: remote private access, subnet routing and Split DNS

The important distinction is that DNS and routing are separate things. AdGuard can correctly answer private.richay.au → 10.10.10.20, but your remote device still needs a Tailscale route to 10.10.10.20 or the browser won’t be able to reach it. DNS can tell you exactly where the house is; it doesn’t magically build the bloody road there.

Split DNS is not an access-control system. Split DNS decides which DNS server answers a query. Subnet routes decide how packets reach the private IP. Tailscale Grants/ACLs decide whether that traffic is actually allowed. Three different jobs, even though they’re all holding hands by the end of this guide.

1. Install Tailscale on the subnet router

Install Tailscale on a machine that can reach both your DNS server and reverse proxy over the LAN. It doesn’t need to be anything fancy — it just needs to sit in the right spot and not randomly disappear when you need it.

In this example I’m installing Tailscale on the Traefik host at 10.10.10.20. That machine will also act as a Tailscale subnet router.

You can install Tailscale using the current Linux installation instructions.

2. Enable IP forwarding

Tailscale requires IP forwarding on a Linux subnet router. For most modern Linux distributions with /etc/sysctl.d:

echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf


If you’re only using IPv4 you technically only need IPv4 forwarding, but I’ve left Tailscale’s current IPv4 + IPv6 example intact. Future me can deal with IPv6 properly one day instead of pretending it doesn’t exist 😅.

3. Advertise the private routes

This is the bit I had slightly wrong in the original version of this guide. DNS was working, so naturally I stared at DNS for too long even though the missing bit was actually routing. Classic.

The remote Tailscale client needs to reach both the DNS server at 10.10.10.10 and the reverse proxy at 10.10.10.20. Advertising only the AdGuard address lets DNS queries work, but doesn’t create a route to the private IP returned by AdGuard. So you get a perfectly correct DNS answer followed immediately by absolutely fuck-all happening in the browser.

For the tightest routing, advertise only those two hosts:

sudo tailscale set --advertise-routes=10.10.10.10/32,10.10.10.20/32


If you deliberately want your Tailscale devices to access the whole LAN instead, you could advertise the subnet:

sudo tailscale set --advertise-routes=10.10.10.0/24


I prefer the two /32 routes for this guide because they expose only the DNS and reverse-proxy hosts rather than the whole subnet. No need to hand Tailscale the keys to the whole neighbourhood when two houses will do.

4. Approve the routes in Tailscale

Open the Tailscale Machines page, locate the subnet-router machine, open its menu and choose Edit route settings.

Approve 10.10.10.10/32 for AdGuard and 10.10.10.20/32 for Traefik.

Route approval and Tailscale access policy are separate. The route makes those addresses reachable through the tailnet; your Grants/ACLs decide who is actually allowed to connect to them. Reachable does not automatically mean invited in.

Linux client note: Windows, macOS, iOS and Android accept advertised subnet routes by default. Linux clients do not. On a Linux client that needs to use these routes, run:

sudo tailscale set --accept-routes=true


5. Configure Tailscale Split DNS

Now open the Tailscale DNS page.

  1. Select Add nameserver → Custom.
  2. Enter your internal DNS server: 10.10.10.10.
  3. Enable Restrict to search domain.
  4. Enter richay.au as the search domain.
  5. Save the nameserver.

Tailscale calls this a restricted nameserver. It is also commonly called Split DNS. Same idea, slightly more corporate name.

With that configured, DNS queries for names under richay.au are sent to AdGuard while you’re connected to Tailscale. Normal DNS queries can continue using the device’s normal resolver.

You do not need to enable Override DNS servers just to use this Split DNS setup. Override DNS servers is for forcing global tailnet DNS behaviour, whereas here we only want richay.au queries sent to AdGuard. No point hijacking every DNS query when we’re only interested in our own little corner of the internet.

Tailscale Split DNS custom nameserver configuration

6. Configure AdGuard Home

AdGuard now needs to return the internal Traefik address for your private services. This is where the whole thing starts feeling suspiciously clever for something that’s actually pretty simple.

In my setup I use a wildcard DNS rewrite equivalent to:

*.richay.au  →  10.10.10.20


This means names such as:

code-server.richay.au
proxmox.richay.au
homeassistant.richay.au


can all resolve to Traefik’s private IP without creating public DNS records for every private service. Which is exactly what I want — nice names internally, no giant public sign saying “hey look, here’s all my homelab shit”.

The original article called this a CNAME record. That’s not quite right when you’re directly rewriting a wildcard name to an IP address — this is better described as an AdGuard wildcard DNS rewrite. Tiny terminology fix, but if I’m going back through these articles I may as well stop lying to future me 😄.

7. Let Traefik route the hostname

Traefik can continue routing each hostname to the correct application exactly as it does on your LAN. It doesn’t care that the request came in through Tailscale; as far as Traefik is concerned, business as usual.

The connection path is:

Remote device
    ↓
Tailscale
    ↓
Split DNS query for app.richay.au
    ↓
AdGuard 10.10.10.10
    ↓
Returns 10.10.10.20
    ↓
Tailscale subnet route
    ↓
Traefik 10.10.10.20
    ↓
Private web app


Public applications can remain completely separate. For example, this blog can still have a public Cloudflare Tunnel while code-server.richay.au exists only in your private DNS. Public stuff stays public, private stuff stays private, and the two don’t need to become one giant networking casserole.

Access control

Don’t confuse private DNS with permission. Hiding the sign doesn’t lock the door.

A device that knows the private IP does not automatically gain access unless your Tailscale policy allows the connection. Tailscale now recommends Grants for new access-control policies; legacy ACLs still work but aren’t receiving new features.

If your tailnet is just your own trusted devices, the default policy may be enough. For a larger tailnet, use Grants to limit which users/devices can reach the advertised private addresses and ports.

Test the setup

Connect a phone or laptop to Tailscale while away from your home network. Mobile data is handy here so you know you’re genuinely testing the remote path and not accidentally proving that your own Wi-Fi still works. First confirm the private DNS server and reverse proxy are reachable:

ping 10.10.10.10
ping 10.10.10.20


On Windows, Tailscale recommends using Resolve-DnsName rather than relying on nslookup when testing advanced DNS behaviour such as Split DNS:

Resolve-DnsName -Name code-server.richay.au


The result should return 10.10.10.20. Then open the private hostname in your browser:

https://code-server.richay.au


Troubleshooting

If DNS resolution fails, check that Tailscale can reach AdGuard and that the restricted nameserver is configured for the correct domain. Don’t start kicking Traefik yet — it hasn’t even been invited to this part of the problem.

If DNS resolves correctly to 10.10.10.20 but the website won’t open, that’s usually a routing or access-policy problem, not DNS. Confirm the Traefik address is included in the advertised and approved routes. This is the point where DNS has done its job and gets to sit back while routing takes the blame.

Useful checks:

tailscale status
tailscale status --routes


If a Linux client can reach normal Tailscale 100.x addresses but not the advertised LAN addresses, enable subnet route acceptance:

sudo tailscale set --accept-routes=true


If Linux DNS itself is behaving strangely, make sure your resolver configuration is compatible with Tailscale. On distributions that use it, Tailscale recommends systemd-resolved rather than a manually overwritten or locked /etc/resolv.conf.

The end result

With this setup I can keep genuinely public services public, while the rest of my homelab uses the same nice *.richay.au hostnames without exposing those applications to the internet.

At home, AdGuard resolves the private names directly. Away from home, Tailscale Split DNS sends only richay.au lookups back to AdGuard, and the subnet routes carry the connection to Traefik.

Much nicer than remembering a pile of IP addresses and ports 🙂

Once this is working it’s one of those setups that feels almost suspiciously tidy: same domain everywhere, no public DNS records for the private stuff, and remote access behaves like you’re sitting at home. Took a few moving parts to get there, but bloody hell it’s nice when it all clicks 🙂

References

Portainer and Authentik / Traefik failling to login due to UFW

Status: CURRENT — ENVIRONMENT-SPECIFIC FIX
Last reviewed: 31 August 2026
Applies to: Portainer + Authentik OAuth/OIDC behind Traefik with UFW enabled

This was the fix for my setup. If disabling UFW immediately makes the Authentik login work, the firewall path is involved. Docker and UFW interact in slightly weird ways though, so don’t assume every Docker host will behave exactly the same.

I use Authentik with Traefik for SSO, and Portainer authenticates against Authentik using OAuth/OIDC.

Authentik still has a Portainer integration guide, although that page still notes it was originally based on Authentik 2021.7.3 and Portainer 2.6.x. Portainer’s current documentation still supports a Custom OAuth provider.

This post isn’t another full Portainer/Authenik setup guide. It’s for the stupid problem I hit after the OAuth configuration looked correct.

The problem

When I tried logging into Portainer through Authentik, the login failed. Inspecting the browser console showed a request returning 404, which eventually resulted in a 401 Unauthorized.

Portainer Authentik login failure
Browser console showing the authentication request error

Portainer, Authentik and Traefik were already on the expected Docker networks and changing the Traefik configuration wasn’t getting me anywhere.

The thing that finally exposed the problem was testing with UFW disabled.

Check UFW first

Before changing anything, check the current firewall state:

sudo ufw status verbose


If Traefik is supposed to be reachable over HTTPS but there is no rule allowing the traffic you expect, that is worth investigating before ripping apart your Authentik configuration.

Temporarily disable UFW as a test

Diagnostic test only: disabling the firewall removes protection from the host while it is off. Don’t leave UFW disabled as the fix.

Temporarily disable UFW:

sudo ufw disable


Now try the Portainer → Authentik login again.

In my case it immediately worked. That told me the OAuth configuration itself wasn’t the problem — traffic involved in the HTTPS authentication flow was being blocked by my firewall setup.

Why being on the same Docker network didn’t save it

This caught me out because Portainer, Authentik and Traefik were on Docker networks together.

OAuth/OIDC isn’t necessarily just container-to-container traffic. Your browser is redirected between the Portainer and Authentik HTTPS URLs, and the callback returns through those hostnames. If the HTTPS path to Traefik is broken, the authentication flow can fail even though the containers themselves can communicate internally.

Docker + UFW warning: Docker’s normal published ports are implemented with Docker-managed firewall/NAT rules, and Docker documents that published container ports can bypass the normal UFW INPUT/OUTPUT rules.

Because of that, ufw allow 443 is not universally required for every Docker-published Traefik instance. If toggling UFW changes the result on your host, inspect how Traefik exposes 80/443 and any custom forwarding/firewall rules rather than assuming this exact fix applies everywhere.

Check how Traefik is listening

A quick check on the Docker host:

sudo ss -lntp | grep -E ':(80|443)\b'


You can also check Docker’s published ports:

docker ps --format 'table {{.Names}}\t{{.Ports}}'


This helps work out whether Traefik is listening directly on the host, using Docker port publishing, host networking, or something more customised.

The fix on my host

Once I confirmed UFW was involved, I re-enabled it and allowed HTTP/HTTPS for Traefik.

If you’re connected to the server remotely over SSH, make sure SSH is already allowed before enabling UFW or you can lock yourself out.

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp comment 'Allow HTTP for Traefik'
sudo ufw allow 443/tcp comment 'Allow HTTPS for Traefik'
sudo ufw enable
sudo ufw reload


Then confirm the rules:

sudo ufw status numbered


After that, the Portainer login through Authentik worked normally again.

Do you actually need port 80?

Not necessarily. If your Traefik setup only needs HTTPS and you aren’t using port 80 for an HTTP → HTTPS redirect or an ACME HTTP challenge, then you may only need:

sudo ufw allow 443/tcp comment 'Allow HTTPS for Traefik'


Open only what your setup actually needs.

If disabling UFW does NOT fix it

Then stop blaming UFW 😅 and go back through the OAuth flow.

  • Confirm the Portainer redirect URL exactly matches the redirect URI configured in Authentik.
  • Confirm Portainer’s Authorization, Access Token and Resource/UserInfo URLs point at the correct Authentik instance.
  • Check the Client ID and Client Secret.
  • Portainer’s OAuth scopes should be space-separated, not comma-separated.
  • Confirm both the Portainer and Authentik hostnames are reachable from the browser performing the login.
  • Check Traefik and Authentik logs while reproducing the login.

Portainer’s current documentation still supports Settings → Authentication → OAuth → Custom, so the general Authentik-as-OIDC-provider design remains valid.

Summary

For me, the giveaway was simple:

UFW enabled  → Authentik login fails
UFW disabled → Authentik login works


That narrowed the problem from “something is broken in Authentik/Portainer/Traefik” to “the firewall path is involved”, which was a hell of a lot easier to troubleshoot.

Just remember that Docker’s own firewall rules can bypass UFW for normally published container ports, so treat this as a troubleshooting method and my specific fix rather than a universal Docker rule.

References

Proxmox cluster with Traefik

Status: CURRENT
Last reviewed: 31 August 2026
Applies to: Proxmox VE 8/9 cluster + Traefik v3.x

Traefik can sit in front of multiple Proxmox nodes so a single hostname such as proxmox.richay.au reaches whichever cluster node is available. Modern Traefik supports Proxmox shell/noVNC WebSockets without a special WebSocket middleware.

Why put a Proxmox cluster behind Traefik?

Each node in a Proxmox cluster provides the web interface, and Proxmox cluster management can be performed by connecting to any cluster node.

Instead of remembering:

https://10.10.10.1:8006
https://10.10.10.2:8006
https://10.10.10.3:8006


I can use one address:

https://proxmox.richay.au


Traefik then load balances the browser traffic between the available nodes.

Security: Proxmox is a management interface with control over your VMs, containers, storage and cluster. I recommend keeping this hostname private rather than publishing it to the open internet.

I use Tailscale Split DNS for this type of service so proxmox.richay.au resolves only while I’m at home or connected to Tailscale. My guide is here: Tailscale Split DNS by Domain for Secure Home Server Access.

The old WebSocket workaround is no longer needed

The original version of this article added these request headers manually:

Upgrade: websocket
Connection: Upgrade


That is no longer required with current Traefik. Traefik v3 supports WebSocket and WebSocket Secure connections out of the box and automatically handles the protocol upgrade using normal HTTP routing.

So there is no need for a custom websocket-upgrade middleware just to make the Proxmox shell or noVNC console work.

Current Traefik dynamic configuration

This is the modern equivalent of my original config.yaml.

This example assumes your Proxmox nodes are using their normal cluster-generated HTTPS certificates and Traefik is connecting to them by IP address. Because those certificates are normally signed by the private Proxmox cluster CA, the example uses a dedicated ServersTransport with certificate verification disabled.

http:
  routers:
    proxmox:
      entryPoints:
        - "https"
      rule: "Host(`proxmox.richay.au`)"
      service: proxmox
      tls: {}

  services:
    proxmox:
      loadBalancer:
        serversTransport: proxmox-transport

        sticky:
          cookie:
            name: proxmox_lb
            secure: true
            httpOnly: true
            sameSite: lax

        servers:
          - url: "https://10.10.10.1:8006" # Proxmox node 1
          - url: "https://10.10.10.2:8006" # Proxmox node 2
          - url: "https://10.10.10.3:8006" # Proxmox node 3

  serversTransports:
    proxmox-transport:
      insecureSkipVerify: true


That’s it. No manual WebSocket headers.

Why use a sticky cookie?

Traefik’s load balancer normally distributes requests between the backend servers. Enabling a sticky cookie keeps subsequent requests from the same browser session on the same Proxmox node where possible.

For a normal website that may not matter much, but for a management UI with long-running shell/noVNC connections I prefer keeping the browser pinned to one node during the session.

The example also marks Traefik’s affinity cookie as Secure and HTTPOnly.

About insecureSkipVerify

insecureSkipVerify: true does not disable HTTPS. Traffic between Traefik and Proxmox is still encrypted, but Traefik does not verify that the backend certificate is trusted and belongs to the expected server.

That’s convenient for a trusted home management network, but it is weaker than actually trusting the Proxmox cluster CA.

Proxmox creates its own cluster CA by default and generates a node certificate for each node. The public cluster CA is stored at:

/etc/pve/pve-root-ca.pem


If you want proper backend certificate verification, copy only the public CA certificate to the Traefik host/container and use node hostnames that match the certificates.

For example:

http:
  services:
    proxmox:
      loadBalancer:
        serversTransport: proxmox-trusted
        sticky:
          cookie:
            name: proxmox_lb
            secure: true
            httpOnly: true

        # Use the actual DNS names present in your node certificates.
        servers:
          - url: "https://pve1.home.arpa:8006"
          - url: "https://pve2.home.arpa:8006"
          - url: "https://pve3.home.arpa:8006"

  serversTransports:
    proxmox-trusted:
      rootCAs:
        - /etc/traefik/certs/pve-root-ca.pem


This is the better option if you want end-to-end TLS verification rather than simply trusting the management LAN.

Do not copy the Proxmox cluster CA private key to Traefik. The public pve-root-ca.pem certificate is all Traefik needs to trust the cluster certificates.

HTTPS redirect

My old dynamic configuration attached an HTTPS redirect middleware to the router even though that router was already listening on the HTTPS entrypoint. That’s redundant.

If you want every request hitting port 80 to redirect to HTTPS, I prefer doing that once on the HTTP entrypoint in Traefik’s static/install configuration:

entryPoints:
  http:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: https
          scheme: https

  https:
    address: ":443" 


Then every service can use the HTTPS entrypoint without carrying around its own redirect middleware.

Testing it

Open the single cluster hostname:

https://proxmox.richay.au


Log into Proxmox and test:

  • Normal cluster navigation.
  • A node shell.
  • An LXC console.
  • A VM noVNC console.

They should work through the same Traefik route without any WebSocket-specific middleware.

If the normal web UI loads but consoles fail, check the browser developer tools for the WebSocket request and check the Traefik logs. Don’t immediately add fake Upgrade headers back in — modern Traefik already handles that part.

What happens when a node goes down?

Traefik can distribute requests across multiple Proxmox nodes, but this alone is not a full HA design. If the node your sticky cookie points to disappears, Traefik can select another backend when the failed backend is detected, but an existing shell/noVNC connection to the dead node will obviously be lost.

The reverse proxy gives you one convenient cluster URL. Proxmox clustering/HA is still what handles the actual guests and cluster state.

Archived note from the original article

ARCHIVED — old Traefik WebSocket middleware

The original article used a websocket-upgrade Headers middleware with Upgrade: websocket and Connection: Upgrade. That workaround has been removed from the active configuration because current Traefik handles WebSocket upgrades automatically.

References

Much cleaner now — one cluster URL, sticky sessions, working shells, and no janky WebSocket header hack required 🙂

Authentik and Home Assistant with working Companion App solution using Traefik proxy

Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Home Assistant, Authentik and Traefik with the Home Assistant Companion App

The original two-hostname workaround in this article still explains a real problem, but the component it relied on has now been archived. In 2026 I recommend authenticating Home Assistant directly with Authentik over OIDC instead of putting Authentik Forward Auth in front of Home Assistant.

I absolutely love logging into Authentik once and getting SSO across the homelab. Home Assistant used to be one of the awkward exceptions.

The original version of this article solved that by creating two Traefik routes:

  • A browser hostname protected by Authentik Forward Auth, using hass-auth-header to log the user into Home Assistant.
  • A second hostname that bypassed Authentik and showed Home Assistant’s normal login page so the Companion App could authenticate.

That workaround worked, and judging by the traffic/comments it helped quite a few people. But the Home Assistant authentication landscape has changed enough that I wouldn’t build a fresh setup that way now.

What changed?

The BeryJu/hass-auth-header component used by the original guide was archived on 23 October 2025. Its maintainer specifically points users toward newer Home Assistant OIDC integrations instead.

A maintained community project called OpenID Connect for Home Assistant now lets Home Assistant itself authenticate against Authentik using standard OIDC.

That means Authentik no longer needs to sit in front of Home Assistant as a Forward Auth middleware. Traefik just proxies Home Assistant normally, and Home Assistant performs the Authentik login itself.

Why this is better for the Companion App: the maintained OIDC integration is designed to handle Home Assistant login in both the web interface and Companion App. Its maintainer specifically advises removing reverse-proxy-level authentication when migrating from header-auth setups, because an extra authentication layer in front of Home Assistant can interfere with the app’s login flow.

Recommended 2026 setup

The new layout is much simpler:

Browser / Companion App
          ↓
https://home-assistant.richay.au
          ↓
       Traefik
          ↓
   Home Assistant
          ↓
 Authentik via OIDC


There is only one Home Assistant hostname, and there is no Authentik Forward Auth middleware in front of it.

1. Keep Traefik as a normal reverse proxy

Current Traefik does not need any special configuration for Home Assistant’s WebSocket traffic. A normal HTTPS router is enough.

Home Assistant changed its HTTP defaults in 2026.8:

  • Home Assistant OS: new default HTTP server port is 80.
  • Home Assistant Container: default remains 8123.
  • If you’ve manually configured another port, use whatever is shown under Settings → System → Network → HTTP server.

For a current Home Assistant OS install using port 80, my Traefik dynamic config would look roughly like this:

http:
  routers:
    home-assistant:
      entryPoints:
        - "https"
      rule: "Host(`home-assistant.richay.au`)"
      tls: {}
      service: home-assistant

  services:
    home-assistant:
      loadBalancer:
        servers:
          - url: "http://10.10.10.10:80" # Home Assistant OS 2026.8+ default


If you’re running Home Assistant Container, or your HAOS instance still uses the old/custom port, change the backend to:

url: "http://10.10.10.10:8123"


There is deliberately no Authentik middleware on this router. OIDC happens inside Home Assistant.

2. Configure Home Assistant to trust Traefik

Another 2026 change: from Home Assistant 2026.8, the HTTP server/reverse-proxy settings moved out of configuration.yaml and into the UI.

Go to:

Settings → System → Network → HTTP server


Enable Trust X-Forwarded-For and add Traefik under Trusted proxies.

If Traefik reaches Home Assistant from a Docker network such as 172.22.0.0/16, that is the network you would trust.

Don’t blindly trust your entire home LAN. The old version of this article included the whole 10.10.10.0/24 home network as a trusted proxy. Home Assistant’s setting is specifically for reverse proxies that are allowed to supply X-Forwarded-For. Trust only the Traefik IP/network that actually proxies the request.

If you’re still running a Home Assistant release before 2026.8, the equivalent old YAML was:

http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 172.22.0.0/16 # Replace with your Traefik proxy network


3. Install the Home Assistant OIDC integration

Install OpenID Connect for Home Assistant through HACS.

After installing and restarting Home Assistant, go to:

Settings → Devices & Services → Add Integration → OpenID Connect/SSO Authentication


The integration includes an Authentik-specific setup flow.

4. Create the Authentik OIDC application

In Authentik, create an Application with an OAuth2/OpenID Connect Provider.

The current integration guide recommends a Strict redirect URI of:

https://home-assistant.richay.au/auth/oidc/callback


Select an Authentik signing key so the provider uses signed ID tokens. The current Authentik setup guide for this integration uses an RS256 signing key.

Make note of the:

  • Client ID
  • Client Secret, if you’re using a confidential client
  • Application/provider slug

The OIDC discovery URL will look like:

https://auth.example.com/application/o/home-assistant/.well-known/openid-configuration


Replace the Authentik hostname and provider slug with your own.

5. Configure Home Assistant

In the Home Assistant OIDC integration:

  1. Select Authentik.
  2. Enter the Authentik discovery URL.
  3. Enter the Client ID and Client Secret if required.
  4. Configure user/group/role mapping as needed.
  5. Test SSO before removing or disabling any local login method.

Keep a local break-glass Home Assistant admin. Don’t make an external identity provider your only way back into the system until you’ve tested browser and Companion App login properly.

6. Use the same URL in the Companion App

The Companion App can now use the same address as the browser:

https://home-assistant.richay.au


There should no longer be a need for a second home-companion.* hostname just to bypass Authentik, because Authentik is no longer authenticating at the reverse-proxy layer.

Keeping Home Assistant private with Tailscale

You don’t have to publish the Home Assistant hostname to the open internet.

Home Assistant’s own current remote-access documentation lists VPN access such as Tailscale as a secure option. If your phone stays connected to Tailscale, the Companion App can continue updating sensors and reaching Home Assistant through the VPN.

I use Split DNS for this type of setup so the nice *.richay.au names resolve to my internal reverse proxy only while I’m at home or connected to Tailscale.

My guide is here: Tailscale Split DNS by Domain for Secure Home Server Access.

Legacy method — the original two-hostname workaround

LEGACY / NOT RECOMMENDED FOR NEW INSTALLS

I’m keeping this section because this article was widely used and existing installations may still be running it. The method relies on the now-archived hass-auth-header custom component.

The original workaround looked like this:

Browser
  ↓
home-assistant.example.com
  ↓
Traefik + Authentik Forward Auth
  ↓
X-authentik-username
  ↓
hass-auth-header
  ↓
Home Assistant

Companion App
  ↓
home-companion.example.com
  ↓
Traefik WITHOUT Authentik
  ↓
Normal Home Assistant login


That architecture solved the Companion App problem because the app never had to pass through Authentik Forward Auth.

However, the second hostname is not magically secure. If home-companion.example.com is publicly reachable, anyone can reach Home Assistant’s normal login screen through it. Making the hostname obscure is not meaningful protection.

If you continue using this old method, make the Companion hostname private using LAN DNS, Tailscale Split DNS, a VPN, firewall rules, or another real access-control boundary.

Legacy hass-auth-header configuration

The custom component itself still expected something similar to:

auth_header:
  username_header: X-authentik-username


Reverse-proxy trust is separate. On current Home Assistant releases configure Traefik under Settings → System → Network → HTTP server rather than adding a new http: YAML block.

The archived hass-auth-header project itself warns that a bad configuration can allow unintended access to Home Assistant. If you keep using it, only trust headers arriving from your actual reverse proxy and plan a migration to OIDC.

Why Authentik Forward Auth can still break the Companion App

This part of the original article remains relevant.

Reverse proxying Home Assistant is fine. The problem is adding a separate authentication wall in front of Home Assistant.

The current Home Assistant OIDC integration’s maintainer explicitly describes reverse proxy + proxy-level authentication as an unsupported scenario for the Companion App. When the login flow leaves the Home Assistant domain for an extra proxy authentication step, the app can fail to complete its own authorization hand-off.

That’s why the modern solution is:

Traefik: reverse proxy only
Home Assistant: handles authentication
Authentik: OIDC identity provider


rather than:

Traefik: reverse proxy + authentication gate
Home Assistant: receives pre-authenticated headers


Troubleshooting

If the browser works but the Companion App does not:

  • Make sure you are not still applying an Authentik Forward Auth middleware to the Home Assistant router.
  • Make sure the app is using the same Home Assistant hostname configured in the Authentik redirect URI.
  • Confirm Home Assistant trusts Traefik as a reverse proxy.
  • Confirm the OIDC callback is exactly /auth/oidc/callback.
  • Test local Home Assistant login as a fallback before assuming the issue is OIDC.
  • Check Home Assistant logs and Authentik provider/outpost logs during the login attempt.

If Home Assistant returns 400 Bad Request behind Traefik, check the HTTP Server settings and confirm the Traefik IP/network is listed under Trusted proxies.

References

The old workaround did its job, but this is one of those rare cases where the newer method is actually simpler: one hostname, proper OIDC, no header-auth hack, and the Companion App doesn’t need its own secret back door 🙂