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.comhostname 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:
- A domain name managed by Cloudflare (here
myhomelan.com). Cloudflare must be the authoritative DNS for the domain so the DNS-01 challenge works. - A Tailscale account with a tailnet you control, and at least one device (the CasaOS box) already joined.
- A host to run Nginx Proxy Manager (the CasaOS server in this example), itself joined to Tailscale.
- A Kubernetes cluster you can install Helm charts into, and
kubectl/helmaccess to it. - 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:
- My Profile → API Tokens → Create Token.
- Use Create Custom Token (or the “Edit zone DNS” template).
- Permissions:
Zone→DNS→Edit. - Zone Resources:
Include→Specific zone→myhomelan.com(or all zones if you prefer). - 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.comUse 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:
| Field | Value |
|---|---|
| Domain Names | www.myhomelan.com` |
| Scheme | http (or https if the back-end supports it) |
| Forward Hostname / IP | 100.64.10.34 (the Tailscale IP of the target service) |
| Forward Port | the 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:
LoadBalancerwithloadBalancerClass: 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”.- Ingress with
ingressClassName: tailscale— the operator runs an ingress proxy that fronts a Service and gives you ahttps://<hostname>.<tailnet>.ts.netURL with TLS handled by Tailscale. - Egress — an
ExternalNameService annotated withtailscale.com/tailnet-iplets 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-operatorif 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='k...CNTRL' export TS_OAUTH_CLIENT_SECRET='tskey-client-k...CNTRL-...'
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="${TS_OAUTH_CLIENT_ID}" \ --set-string oauth.clientSecret="${TS_OAUTH_CLIENT_SECRET}" \ --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 # <-- the operator keys off this
loadBalancerClass: tailscale # <-- 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='{.status.loadBalancer.ingress[0].ip}'; 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:
| Field | Value |
|---|---|
| Domain Names | hello.myhomelan.com |
| Scheme | http |
| Forward Hostname / IP | 100.64.20.5 |
| Forward Port | 80 |
SSL tab
Select the *.myhomelan.com wildcard cert, Force SSL, HTTP/2
In Cloudflare add a DNS record:
| Type | Name | Content | Proxy Status |
|---|---|---|---|
| A | hello | 100.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: "100.64.10.34"
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
- Tailscale — Tailscale Kubernetes Operator (overview): https://tailscale.com/docs/kubernetes-operator
- Tailscale Blog — Kubernetes Operator announcement and patterns: https://tailscale.com/blog/kubernetes-operator @tailscale.com/blog/kubernetes-operator
- Josh Noll — Securely exposing applications on Kubernetes with Tailscale: https://joshrnoll.com/securely-exposing-applications-on-kubernetes-with-tailscale/ @joshrnoll.com/securely-exposing-applications-on-kubernetes-with-tailscale/
- bahn1075 — tailscale-operator deployment bundle (OAuth/scopes/troubleshooting): https://github.com/bahn1075/tailscale-operator @github.com/bahn1075/tailscale-operator
- ArtifactHub — tailscale-operator Helm chart (repo
https://pkgs.tailscale.com/helmcharts): https://artifacthub.io/packages/helm/tailscale/tailscale-operator @artifacthub.io/packages/helm/tailscale/tailscale-operator - Tailscale — operator Helm
Chart.yaml(source): https://github.com/tailscale/tailscale/blob/main/cmd/k8s-operator/deploy/chart/Chart.yaml - Medium — Homelab Nginx Proxy Manager setup, SSL certificate with Cloudflare DNS-01: https://medium.com/@life-is-short-so-enjoy-it/homelab-nginx-proxy-manager-setup-ssl-certificate-with-domain-name-in-cloudflare-dns-732af64ddc0b @medium.com/@life-is-short-so-enjoy-it/homelab-nginx-proxy-manager-setup-ssl-certificate-with-domain-name-in-cloudflare-dns-732af64ddc0b
- Home Assistant Community — Guide: secure remote mTLS and local HA access (mTLS/Tailscale context): https://community.home-assistant.io/t/guide-secure-remote-mtls-and-local-ha-access/1003197
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/Ingressmanifests, 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 thetag:k8s-operator is invalidtroubleshooting path 【0】.The Medium article confirmed the Cloudflare API token permission (
Zone:DNS:Edit), thedns_cloudflare_api_tokencredential file format, and the 120-second propagation setting for the DNS-01 wildcard cert 【0】.The
serverasode.comandartifacthub.io/GitHubChart.yamlsources 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 showed1.98.9for thephilippevienne/tailscalemirror; the official chart version floats, so I deliberately usedtailscale/tailscale-operatorwithout pinning--versionin the main install command — pin it in production with--version <x.y.z>after checkinghelm 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%