How I built my Homelab around Tailscale

Preamble/Disclaimer

This post is a work in progress, I set this up about 18 months ago, and have used my notes, URLs and AI to put this post together, if something really doens’t make sense or work contact me on Mastodon.

1. What I’m doing

I run a homelab where every service I host gets a Tailscale IP. On my CasaOS server I run Nginx Proxy Manager (NPM) inside a Docker container. NPM acts as my reverse proxy: it terminates HTTPS for my domain *.myhomelan.com and forwards traffic to the Tailscale IP of whichever service is being requested.

HTTPS certificates are issued automatically using Let’s Encrypt with a Cloudflare DNS-01 challenge, so I can mint wildcard certificates without ever opening a port on my router.

The flow, end to end, looks like this:“

Browser ──https://www.myhomelan.com──▶ Cloudflare DNS

└─ A record www.myhomelan.com ─▶ NPM Tailscale IP (e.g. 100.64.10.10)

└─ NPM terminates TLS, proxies to upstream Tailscale IP (e.g. 100.64.10.34)

└─ Service answers on its Tailscale IP (HTTP internally is fine)

The key property: you only get a connection if your device is joined to the tailnet. If you’re not on the tailnet, the Cloudflare A record points at a Tailscale IP that is not routable from the public internet, so the request simply fails. That is the security model — the public DNS name is a convenient alias, the actual reachability is granted by being on the tailnet @tailscale.com/blog/kubernetes-operator.

For the services that live on plain Docker hosts this is straightforward. The challenge this post solves is: how do I give a Kubernetes pod its own Tailscale IP so the same NPM → Tailscale IP pattern works for workloads running in k8s? The answer is the Tailscale Kubernetes Operator, deployed via its official Helm chart.

2. Why I do it this way

  • No inbound ports. Everything rides Tailscale’s WireGuard mesh. Cloudflare only resolves the name; it never proxies the traffic.
  • TLS even for HTTP-only back-ends. NPM terminates TLS, so a service that only listens on plain HTTP still gets https:// from the client’s perspective.
  • Zero-trust reachability. A Tailscale IP is only reachable by authenticated nodes on the tailnet. Public DNS pointing at a Tailscale IP is harmless to anyone not on the tailnet.
  • Single certificate strategy. One wildcard cert from Let’s Encrypt covers every *.myhomelan.com hostname I will ever create.
  • Kubernetes parity. By running the Tailscale operator in the cluster, pods become first-class tailnet nodes and behave exactly like the Docker services — NPM can point at them with no special handling.

3. What needs to be set up

Before you start, you need:

  1. A domain name managed by Cloudflare (here myhomelan.com). Cloudflare must be the authoritative DNS for the domain so the DNS-01 challenge works.
  2. A Tailscale account with a tailnet you control, and at least one device (the CasaOS box) already joined.
  3. A host to run Nginx Proxy Manager (the CasaOS server in this example), itself joined to Tailscale.
  4. A Kubernetes cluster you can install Helm charts into, and kubectl/helm access to it.
  5. Admin access to the Tailscale admin console to create an OAuth client and edit the tailnet policy (ACLs).

4. Setting up Nginx Proxy Manager on Docker Compose

On the CasaOS host, create a working directory and a docker-compose.yml for NPM. The important detail is that the container must join Tailscale so NPM itself is reachable on the tailnet — the cleanest pattern is to run NPM in one compose project and a Tailscale sidecar in another, then point NPM at the tailnet. For a simple single-host setup, run NPM on the host network bridge and rely on the host’s own Tailscale daemon.

# /opt/nginx-proxy-manager/docker-compose.yml
version: "3.8"
services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    container_name: npm
    restart: unless-stopped
    ports:

- "80:80"     # HTTP, used for ACME http-01 fallback if ever needed
- "81:81"     # NPM admin UI
- "443:443"   # HTTPS termination
    volumes:

- ./data:/data
- ./letsencrypt:/etc/letsencrypt
    environment:

- DB_SQLITE_FILE=/data/database.sqlite
- DISABLE_IPV6=true`` 

Bring it up:

cd /opt/nginx-proxy-manager docker compose up -d docker compose logs -f npm

Browse to http://<host-ip>:81 and log in with the default credentials ([email protected] / changeme). Change them immediately when prompted.

If you want NPM itself to live on the tailnet rather than on the host, the common pattern is a separate Tailscale container sharing the NPM container’s network namespace, exposing port 443 via Tailscale Serve or a simple tailscale up --advertise-tags. That is outside the scope of this post; here we assume the host is already on the tailnet and NPM binds the host’s ports.

5. Connecting Nginx Proxy Manager to Cloudflare and issuing the wildcard certificate

5.1 Create a Cloudflare API token

In the Cloudflare dashboard:

  1. My Profile → API Tokens → Create Token.
  2. Use Create Custom Token (or the “Edit zone DNS” template).
  3. Permissions: ZoneDNSEdit.
  4. Zone Resources: IncludeSpecific zonemyhomelan.com (or all zones if you prefer).
  5. Create the token and copy it immediately — it is shown only once @medium.com/@life-is-short-so-enjoy-it/homelab-nginx-proxy-manager-setup-ssl-certificate-with-domain-name-in-cloudflare-dns-732af64ddc0b.

5.2 Add the certificate in NPM

In NPM go to SSL Certificates → Add SSL Certificate → Let’s Encrypt and fill in:

Field- Value –

  • Domain Names – myhomelan.com, *.myhomelan.com


  • Use a DNS Challenge – ✅ enable


  • DNS Provider – Cloudflare


  • Credential File Content –
    dns_cloudflare_api_token=<YOUR_CLOUDFLARE_TOKEN>


  • Propagation Seconds -`120


  • Agree to TOS – ✅


The Credential File Content is a small INI-style snippet that NPM passes to certbot. It must contain the key dns_cloudflare_api_token followed by your token

dns_cloudflare_api_token=AbC123YourTokenHereXYZ

Save. NPM will request the wildcard certificate via DNS-01 — Cloudflare will create and then delete a _acme-challenge TXT record automatically. Because we use DNS-01, no inbound port is required; the challenge proves control of the DNS zone, not of a web endpoint.

5.3 Create the proxy host in NPM

Go to Hosts → Proxy Hosts → Add Proxy Host:

FieldValue
Domain Nameswww.myhomelan.com`
Schemehttp (or https if the back-end supports it)
Forward Hostname / IP100.64.10.34 (the Tailscale IP of the target service)
Forward Portthe service’s port, e.g. 8080
Block Common Exploits
Websockets Support✅ if the service needs them

On the SSL tab:

  • Select the wildcard certificate you just created.
  • Enable Force SSL and HTTP/2 Support.
  • Save.

5.4 Create the matching DNS record in Cloudflare

In Cloudflare → myhomelan.com → DNS: |Type | Name | Content | Proxy Status | |–|–|–|–| | A | www | 100.64.10.10` (the Tailscale IP of the NPM host) | DNS only (grey cloud) |

The A record must point at the NPM host’s Tailscale IP, not the back-end service’s. NPM is the TLS terminator; it then dials the back-end over the tailnet. Set the record to DNS only (grey cloud) — we do not want Cloudflare’s proxy in the path, because Cloudflare’s edge cannot reach a Tailscale IP.

5.5 Test it

While joined to the tailnet, open https://www.myhomelan.com. Cloudflare resolves the name to 100.64.10.10, your Tailscale daemon routes that to the NPM host, NPM terminates TLS with the wildcard cert and forwards to 100.64.10.34. Disconnect from Tailscale and try again — it should fail to connect. That is the desired behaviour: the hostname is public, the reachability is not.


6. Deep dive: the Tailscale Kubernetes Operator

6.1 What the operator actually does

The Tailscale Kubernetes operator runs inside your cluster and watches for Kubernetes resources that ask for Tailscale integration. When it sees one, it spins up a small Tailscale proxy pod in the tailscale namespace and wires that proxy into the resource. It supports three main patterns @tailscale.com/blog/kubernetes-operator:

  1. LoadBalancer with loadBalancerClass: tailscale — the operator provisions a Tailscale node for the Service and exposes it on the tailnet with its own Tailscale IP. This is the closest analogue to “give the pod a Tailscale IP”.
  2. Ingress with ingressClassName: tailscale — the operator runs an ingress proxy that fronts a Service and gives you a https://<hostname>.<tailnet>.ts.net URL with TLS handled by Tailscale.
  3. Egress — an ExternalName Service annotated with tailscale.com/tailnet-ip lets pods in the cluster reach a tailnet IP (e.g. a database) as if it were a local Service.

For the homelab pattern in this post we want pattern #1: each exposed Service becomes its own tailnet device with a stable Tailscale IP that NPM can point at — exactly like the Docker services.

Important: the operator does not use a reusable auth key. It uses OAuth client credentials to authenticate to Tailscale, and then mints ephemeral auth keys for the proxies it creates @github.com/bahn1075/tailscale-operator. This is a common stumbling block — a plain auth key will not work.

6.2 Prepare the tailnet

Create the tailscale namespace

kubectl create namespace tailscale kubectl label namespace tailscale pod-security.kubernetes.io/enforce=privileged

The privileged label is required because the proxy pods Tailscale spins up need to create network devices and set routes @joshrnoll.com/securely-exposing-applications-on-kubernetes-with-tailscale/.

Update the tailnet policy (ACLs)

In the Tailscale admin console, Settings → Tailnet → Access Controls, add a tagOwners block so the operator can stamp its devices with tags:

"tagOwners": {
  "tag:k8s-operator": [],
  "tag:k8s":          ["tag:k8s-operator"]
}

tag:k8s-operator owns the operator itself; tag:k8s is the tag the operator applies to the proxy pods it creates. Without this, the operator will fail with requested tags [tag:k8s-operator] are invalid or not permitted @github.com/bahn1075/tailscale-operator.

Create an OAuth client

In the Tailscale admin console: Settings → OAuth clients → Generate OAuth client.

  • Scopes (Write): Devices Core, Auth Keys, Services.
  • Tags: assign tag:k8s-operator if the UI shows a tag selector @github.com/bahn1075/tailscale-operator.

Copy the Client ID and Client Secret — the secret is shown only once. Export them in your shell so the Helm install can read them:

export TS_OAUTH_CLIENT_ID=&#x27;k...CNTRL&#x27; export TS_OAUTH_CLIENT_SECRET=&#x27;tskey-client-k...CNTRL-...&#x27;

6.3 Install the operator with Helm

The official chart lives on Tailscale’s Helm repository @artifacthub.io/packages/helm/tailscale/tailscale-operator:

helm repo add tailscale https://pkgs.tailscale.com/helmcharts helm repo update

Install (or upgrade) the operator @joshrnoll.com/securely-exposing-applications-on-kubernetes-with-tailscale/:

helm upgrade \ --install \ tailscale-operator \ tailscale/tailscale-operator \ --namespace=tailscale \ --create-namespace \ --set-string oauth.clientId=&quot;${TS_OAUTH_CLIENT_ID}&quot; \ --set-string oauth.clientSecret=&quot;${TS_OAUTH_CLIENT_SECRET}&quot; \ --wait

If your cluster’s container runtime has trouble pulling tailscale/k8s-operator (e.g. CRI-O on some managed clouds rejects short image names), set fully-qualified image repositories @github.com/bahn1075/tailscale-operator:

–set-string operatorConfig.image.repository=docker.io/tailscale/k8s-operator
–set-string proxyConfig.image.repository=docker.io/tailscale/tailscale


Verify the operator is up and that it has joined the tailnet:

kubectl -n tailscale rollout status deploy/operator kubectl -n tailscale logs deploy/operator --tail=50

In the Tailscale admin console → Machines you should now see a device named something like tailscale-operator tagged tag:k8s-operator.

6.4 Deploy an application and give it a Tailscale IP

For this example we’ll deploy a tiny web app (an nginx serving a static page is fine) and expose it through the operator. The pattern is: a Deployment for the workload, and a Service of type: LoadBalancer with loadBalancerClass: tailscale. The operator sees the Service, stands up a Tailscale proxy pod, and advertises the Service on the tailnet.

app.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-web
  namespace: default
  labels:
    app: hello-web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-web
  template:
    metadata:
      labels:
        app: hello-web
    spec:
      containers:

- name: web
          image: nginx:stable
          ports:

- containerPort: 80

name: http
---
apiVersion: v1
kind: Service
metadata:
  name: hello-web
  namespace: default
spec:
  selector:
    app: hello-web
  type: LoadBalancer          # &lt;-- the operator keys off this
  loadBalancerClass: tailscale   # &lt;-- and this
  ports:

- name: http
      protocol: TCP
      port: 80
      targetPort: 80` 

Apply it:

kubectl apply -f app.yaml

Watch the operator react:

Bash

kubectl -n tailscale get pods -w kubectl -n default get svc hello-web -w

Within a few seconds you’ll see a new pod appear in the tailscale namespace (the proxy the operator spun up for this Service), and kubectl get svc hello-web will show an EXTERNAL-IP in the Tailscale CGNAT range (100.64.0.0/10). In the Tailscale admin console a new machine appears, named default-hello-web (i.e. <namespace>-<servicename>)

Grab the IP that was assigned:

kubectl -n default get svc hello-web \ -o jsonpath=&#x27;{.status.loadBalancer.ingress[0].ip}&#x27;; echo

Let’s say it returns 100.64.20.5. That is the Tailscale IP of this Kubernetes workload — exactly the same shape as the Tailscale IPs your Docker services have.

6.5 Wire it into Nginx Proxy Manager and Cloudflare

Now that the k8s service has a Tailscale IP, the rest of the flow is identical to §5.

In NPM add a new Proxy Host:

FieldValue
Domain Nameshello.myhomelan.com
Schemehttp
Forward Hostname / IP100.64.20.5
Forward Port80

SSL tab

Select the *.myhomelan.com wildcard cert, Force SSL, HTTP/2

In Cloudflare add a DNS record:

TypeNameContentProxy Status
Ahello100.64.10.10 (NPM host’s Tailscale IP)DNS only (grey cloud)

Test from a tailnet device:

curl -I https://hello.myhomelan.com

You should get a 200 / 301 from nginx, served over TLS from NPM, which proxied the request over the tailnet to the k8s pod’s Tailscale IP. Disconnect from Tailscale and the same request fails — the security model holds.

6.6 Optional: Tailscale-native HTTPS instead of NPM

If you don’t need the myhomelan.com hostname and are happy with a *.ts.net name, the operator’s Ingress mode gives you TLS for free without NPM at all. Flip the Service back to ClusterIP and add an Ingress @joshrnoll.com/securely-exposing-applications-on-kubernetes-with-tailscale/:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello-web
  namespace: default
spec:
  ingressClassName: tailscale
  defaultBackend:
    service:
      name: hello-web
      port:
        number: 80
  tls:
    - hosts:
        - hello-web

With HTTPS and MagicDNS enabled on the tailnet, the service becomes reachable at https://hello-web.<tailnet-name>.ts.net with a Tailscale-issued certificate. This is the simplest option if you don’t care about pretty public hostnames.

6.7 Optional: egress — reaching a tailnet service from inside the cluster

The reverse direction is useful too: letting pods reach a tailnet-only service (e.g. an internal Docker service) as if it were a cluster Service. Create an ExternalName Service annotated with the target’s Tailscale IP @tailscale.com/blog/kubernetes-operator:

apiVersion: v1
kind: Service
metadata:
  name: internal-db
  namespace: default
  annotations:
    tailscale.com/tailnet-ip: &quot;100.64.10.34&quot;
spec:
  type: ExternalName
  externalName: unused   # the operator will overwrite this` 

The operator deploys an egress proxy and rewrites spec.externalName to the proxy’s DNS name. Pods can then reach internal-db.default.svc.cluster.local and the traffic egresses onto the tailnet to 100.64.10.34.

`

8. Wrapping up

The mental model to keep is: Tailscale is the network; DNS is just an alias. Cloudflare gives the alias a name, NPM terminates TLS and forwards, and Tailscale provides the actual reachability. Whether the back-end is a Docker container on CasaOS or a pod in a Kubernetes cluster, the pattern is identical — point NPM at a Tailscale IP, attach the wildcard cert, add a grey-cloud A record in Cloudflare, done.

The Kubernetes operator’s job is simply to make pods participate in that model by giving each exposed Service its own Tailscale identity and IP. Once you have the OAuth client, the tagOwners policy, and the Helm chart installed, exposing a new k8s workload is a one-file change (type: LoadBalancer + loadBalancerClass: tailscale) — no manual tailscale up, no auth keys, no port forwarding.

References

A few notes on the sources and choices:

  • I leaned on the Tailscale blog for the operator’s three patterns (LoadBalancer class, Ingress class, ExternalName egress) and on Josh Noll’s walkthrough for the concrete Helm install and LoadBalancer/Ingress manifests, since the official operator overview page is mostly navigational 【0】【1】.


  • The bahn1075 repo was useful specifically for the gotcha that OAuth credentials (not auth keys) are required, the exact write scopes (Devices Core, Auth Keys, Services), the CRI-O fully-qualified-image workaround, and the tag:k8s-operator is invalid troubleshooting path 【0】.


  • The Medium article confirmed the Cloudflare API token permission (Zone:DNS:Edit), the dns_cloudflare_api_token credential file format, and the 120-second propagation setting for the DNS-01 wildcard cert 【0】.


  • The serverasode.com and artifacthub.io/GitHub Chart.yaml sources didn’t yield usable body content via fetch (one had a URL typo, the others were JS-rendered or raw-file redirects), so I didn’t cite them beyond the repository URL on ArtifactHub. The chart version on ArtifactHub showed 1.98.9 for the philippevienne/tailscale mirror; the official chart version floats, so I deliberately used tailscale/tailscale-operator without pinning --version in the main install command — pin it in production with --version <x.y.z> after checking helm search repo tailscale/tailscale-operator.


  • The Home Assistant mTLS guide you listed is about mutual TLS for Home Assistant rather than NPM/Cloudflare specifically, so I included it as a reference for the broader “secure remote access” context rather than as a cited source for any particular step.


  • GitHub – bahn1075/tailscale-operator: Tailscale Kubernetes Operator deployment for OCI OKE · GitHub github.com 36%


  • Medium medium.com 27%


  • Securely Exposing Applications on Kubernetes With Tailscale — Josh Noll joshrnoll.com 20%


By admin

Leave a Reply

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