Omegion

Proxmox Homelab: Exposing Portainer Apps with Tailscale

Introduction

Between the Terraform-managed VM and backups landing on the NAS, the Portainer box is solid, but everything on it is LAN-only. To check on it, or hit an app running behind it, I have to be home. This post gets remote access working without opening anything to the public internet - Tailscale instead.

Why Tailscale Here, Not a Public Tunnel

I already wrote about exposing a service through a Cloudflare Tunnel gated by Zero Trust identity. That’s the right tool when something needs to be reachable by anyone with the right login. This isn’t that - it’s just me, wanting to reach my own homelab from my own devices. Tailscale builds a private mesh network (WireGuard under the hood) between devices I’ve enrolled myself; nothing gets a public hostname or a public IP at all, so there’s no login screen to gate because there’s no public entry point to gate in the first place.

Prerequisites

  1. The Proxmox VM and Portainer setup from the first post.
  2. A Tailscale account and tailnet.
  3. A reusable Tailscale auth key (Settings > Keys > Generate auth key, tick “Reusable” - a plain key from a logged-in session, not an OAuth-client key, which Tailscale requires to be tagged).

What tsdproxy Does

tsdproxy watches the Docker socket and, for containers with the right labels, registers each one as its own node on the tailnet with its own hostname - opt-in per container, rather than one subnet router exposing the whole LAN at once. That fits Portainer well: I add a label to whatever stack I want reachable, and nothing else on the box is affected.

The tsdproxy Stack

I added this the same way as everything else in this series: another entry in services, deployed as its own portainer_stack by Terraform, not clicked together in the Portainer UI. Rather than paste the compose file here and let it drift from what I actually run, the real one lives in the repo from the first post: templates/tsdproxy.yaml.tftpl. The parts worth calling out:

  • TSDPROXY_AUTHKEY only seeds /config/tsdproxy.yaml on tsdproxy’s first boot - later restarts read the config file it already wrote, not the env var again.
  • tsdproxy isn’t on the same Docker network as the containers it proxies (every Portainer stack gets its own default network), so it reaches them over host.docker.internal at whatever host port they’ve published - extra_hosts: host.docker.internal:host-gateway is what makes that resolve. This is tsdproxy’s documented pattern for exactly this situation - two containers in separate Compose stacks.

Wiring it in is one services entry in terraform.tfvars:

hcl
services = [
  # ... the whoami stack from the first post ...
  {
    name         = "tsdproxy"
    compose_file = "tsdproxy.yaml.tftpl"
    env = {
      TS_AUTHKEY = "<your reusable Tailscale auth key>"
    }
  },
]
shell
❯ terraform apply
...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Labeling the Dummy App

tsdproxy discovers containers the same way Traefik does - two labels on the container itself, not a separate config file per app:

yaml
labels:
  tsdproxy.enable: "true"
  tsdproxy.name: "whoami"

I added those to the same whoami stack from the first post - see the current version in templates/whoami.yaml.tftpl. tsdproxy.enable is what gets it picked up at all; tsdproxy.name sets the hostname it shows up under on the tailnet - drop it and tsdproxy falls back to the container name. The full label reference (multi-port apps, TCP/UDP proxying, health checks) is in tsdproxy’s docs if you need more than the two labels here.

Reaching It From a Tailnet Device

Once the stack redeployed with that label, whoami shows up as its own machine in the Tailscale admin console, with its own *.ts.net hostname and a Tailscale-issued HTTPS certificate for it:

shell
❯ curl -s https://whoami.my-tailnet.ts.net
Hostname: a1b2c3d4e5f6
IP: 172.18.0.2

Ran from my laptop, off the home LAN entirely, over the tailnet.

Conclusion

The barrier here isn’t an identity policy the way the Cloudflare Zero Trust post’s Okta setup was - it’s simpler than that. Nothing is reachable at all unless the requesting device is already enrolled in my tailnet, so there’s no separate access policy to configure or get wrong. That’s the whole arc I set out to cover for this box: a VM built by Terraform, Portainer running stacks on it declaratively, backups landing on the NAS on a schedule, and now remote access without a single port opened on my router.