K3s Migration
Tailscale-private cluster
8 services live
Case Study · Project Level Up Javid

Migrating 19 Services to a
Tailscale-Private K3s Cluster

A real migration from Docker Compose to k3s, where every workload is reachable only over an encrypted Tailscale mesh — zero public ports. Built as CKA exam prep and Senior Platform Engineer portfolio work.

📅 Started 2026-08-17 ✅ 8 of 19 + 1 net-new 💰 $0 / month 🎓 CKA alignment
TL;DR
Eight services now run on a single-node k3s cluster behind a Tailscale Ingress, with zero public ports. Every core migration pattern is proven — stateless+HA, stateful, database, dependent pair, sidecar elimination, multi-container pod, multi-tier, and stateful-with-init-job. No production service remains on Docker Compose, and a migration that failed outright on Compose was later redeemed on k3s.
8 of 19 services on k3s + 1 net-new · updated 2026-09-25 42% COMPLETE
Migrated 42%
On k3s
8 services
On Compose
0 production
Public ports
0
Ingress devices
6
Remaining on Docker 3 containers, all dockhand (the docker-management UI, deliberately aborted). The other compose directories are dormant dev configs with no running containers.
Single-node k3s + Tailscale Operator · v1.36.4+k3s1 LIVE
┌────────────────────────────────────────────────────────────────────┐ │ TAILNET (100.64.0.0/10, WireGuard-encrypted) │ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ THIS BOX — modccd-srva (100.103.195.22) │ │ │ │ │ │ │ │ k3s v1.36.4 single-node (systemd, embedded etcd) │ │ │ │ ├── kube-system coredns · traefik · metrics-server │ │ │ │ ├── tailscale operator + HA proxy devices │ │ │ │ ├── geopolitics dashboard ×2 replicas ──┐ │ │ │ │ ├── gitea gitea + hostPath PVC │ │ │ │ │ ├── litellm gateway + postgres │ Ingress │ │ │ │ ├── agent-vault Infisical │ devices │ │ │ │ ├── mlflow mlflow + caddy sidecar │ │ │ │ │ └── open-notebook notebook + surrealdb ──┘ │ │ │ │ │ │ │ │ NO public ingress · NO exposed ports · hostPath PVs │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ Reached from: laptop (Windows) · Android · the Linux host │ └────────────────────────────────────────────────────────────────────┘
How a request reaches a workload Tailscale DERP/WireGuard → operator-managed proxy device → k3s Service (ClusterIP) → app pod. No router port-forward, no public DNS, no TLS cert to manage — the tailnet identity is the authentication layer.
1
Geopolitics Dashboard · 2026-09-22 STATELESS + HA

The headline milestone: a live production service moved with zero downtime, from a systemd unit to a 2-replica Deployment. HA from day one.

Replicas
2 (was 1)
Memory
64Mi req / 256Mi lim
Rollback
kubectl scale
2
Gitea · 2026-09-22 STATEFUL

First stateful service. SQLite plus repositories migrated to a hostPath PVC, data integrity verified by row count before the old container was decommissioned.

Repos verified
12
Replicas
1 (SQLite)
Volume
5Gi · Retain
3
litellm + postgres · 2026-09-22 DATABASE + AI

A database and its consumer migrated as one unit, so neither was left unable to reach the other. Credentials moved from a .env file to a Secret.

Tables verified
12
Spend logs
54
Health
db: connected
4
Agent Vault · 2026-09-23 SIDECAR ELIMINATED

The headline architectural win. A whole Tailscale sidecar container — with kernel capabilities, its own tailnet identity, and its own auth key — replaced by one declarative field.

cap_add
removed
/dev/net/tun
removed
Auth key
→ Ingress
5
MLflow · 2026-09-23 AUTH SIDECAR

First multi-container pod: MLflow plus a Caddy basic-auth proxy as in-pod sidecars. The Service deliberately targets the proxy's port so auth cannot be bypassed.

Containers
2 / pod
Store
19 tables
Unauth probe
401 ✅
Why 401 is the pass An unauthenticated request reaching that 401 proves the whole chain survived the move. A 200 would have meant the auth layer was bypassed — a security regression.
6
Open Notebook · 2026-09-23 MULTI-TIER

Two Deployments talking over Service DNS. This is also the redemption run: the same service had failed outright on Compose after 18 iterations.

Deployments
2
Migrations
23 applied
Auth
enabled
7
litellm Gateway · 2026-09-23 SHARED AI GATEWAY

Every AI workload now routes through one in-cluster gateway instead of each holding provider keys. Repaired from 3 broken routes to 13 of 14 working.

Models
15 configured
Routes OK
13 / 14
Local model
free · offline
8
Obsidian LiveSync + CouchDB · 2026-09-23 STATEFUL + INIT JOB

Self-hosted Obsidian vault sync, tailnet-only — keeps native Obsidian on desktop and mobile while replacing only the paid sync service. Chosen over browser-based alternatives; one container, one port.

Pod
0 restarts
Unauth data API
401 ✅
Memory
81Mi actual
Restart-safe
verified
The probe trap CouchDB's require_valid_user applies to every request including /_up, so a k8s httpGet probe gets 401 — and the liveness probe then kills a perfectly healthy database in a restart loop. Fixed with the require_valid_user_except_for_up carve-out and authenticated exec probes.
9
Vaultwarden · 2026-09-25 NET-NEW SERVICE

Not a migration — a local-first build. A Bitwarden-compatible server was shared as a Cloudflare Workers project; it was evaluated and skipped because a Worker is a public endpoint, and the native Bitwarden clients cannot present Cloudflare Access tokens, so it could not be made tailnet-only. Built the equivalent on k3s instead: same official clients, zero public ports, no vendor data plane.

Public ports
0
Schema
29 tables
Restart-safe
verified
Memory
34Mi actual
Verified by restart, not by health A green pod on first boot proves little. Across a Recreate rollout the vault's rsa_key.pem stayed byte-identical and all 29 tables remained on the PV — that is what proves the mount and persistence actually work.
The probe that lied A plain curl at the websocket route returned 404, which looked like a broken endpoint. With the upgrade headers and --http1.1 it returns 401 — the route was fine all along. Test the protocol the clients use, not a convenient approximation of it.
13 sidecars of plumbing → one ingress field · the portfolio argument

The original stack reached the tailnet by giving every service its own Tailscale sidecar container. Kubernetes collapses all of that into a single declarative field.

Tailscale integration
ingressClassName
Kernel privileges
none
Auth keys
1 cluster-wide
State volume
none
Secrets
k8s Secrets
Updates
rolling
What that removes Multiplied across the 13 services that used the sidecar pattern: 13 containers, 13 tailnet devices, 13 auth keys, and 13 sets of kernel capabilities — replaced by one field per service.
!
The docker cp nesting trap · hit 3 separate times RECURRING

docker cp svc:/data - preserves the data/ parent, so data lands one level too deep. The pod boots with an empty volume and shows a fresh-install screen instead of real data.

# wrong — nests data/data/
docker cp svc:/data - | tar xf - -C <pv>

# correct — trailing /. on the SOURCE
docker cp svc:/data/. - | tar xf - -C <pv>
find <pv> -maxdepth 2 -type d   # always verify before applying
!
Migrated service served stale data for 3 days · 2026-09-25 — found by the user MIGRATION REGRESSION

A file-reading service was migrated with its data COPY-ed into the image and no volume mount, so main.py read the archive frozen at build time while the cron kept writing fresh data to the host. Pods 1/1 Running, ingress 200, HTML rendering correctly — and the content three days old.

Pod served
2026-09-22
Host had
2026-09-25
Detected by
the user, not monitoring
Two faults, not one The watchdog probed the old systemd port (127.0.0.1:8890) rather than the Tailscale ingress people actually browse, so it could only ever detect a crash — never staleness or a wrong route. A second near-miss: the page did show a current date, but that was the sidebar date picker, not the data. Comparing newest-served against newest-on-disk is what exposed it.
Fix Mount the live project directory at /app so code and data both come from the host (matching the systemd unit's WorkingDirectory); rebuild the watchdog with two lanes — user-facing liveness and data freshness, with an unreadable probe reported as UNKNOWN rather than silently passing.
!
The repair that appeared to lose data · worst moment of the migration NEAR MISS

The first nesting repair copied a parent directory into its own child, then deleted the source — leaving an empty directory. The data looked gone.

Rule now written into the deployment skill Never repair a nesting bug by copying a parent into its own child. Move the innermost contents up instead — and verify with find before touching anything.
!
tailscale serve reset nuked six unrelated mounts · 2026-09-22 SELF-INFLICTED

reset is global, not per-mount. Used it to remove one path and wiped every other serve on the box. All six were restored manually.

!
hostNetwork + 2 replicas · scheduler said "no free ports" MISLEADING ERROR

Two pods cannot share one host port. The scheduler's message implies the port is taken on the host — it is not; the scheduler simply refuses to bind two pods to it.

!
A gateway route that failed for a non-obvious reason · 2026-09-23 WRONG KEY IN THE VAR

Two routes returned Invalid API Key for weeks. The config was correct — the env var held the wrong provider's key: GROQ_API_KEY contained an xai- key, not a gsk_ one.

Fix Verified the key's real owner by testing it, renamed it to XAI_API_KEY, and left GROQ_API_KEY deliberately empty so availability checks report honestly instead of failing later at request time.
✓
One failure was fully redeemed · Open Notebook, 2026-09-15 → 2026-09-23 REDEEMED

Open Notebook had already failed once — a four-hour Compose attempt aborted after 18 iterations, ending in a 502. The cause was architectural, not a config mistake: three services shared one Tailscale sidecar's network namespace, and every consumer bound the sidecar's ephemeral container ID. Any sidecar restart orphaned all of them.

Compose attempt
~4h · ABORT
k3s attempt
~35 min · working
Iterations
18 → 0
The decisive test Restarting the database — the exact operation that produced the 502 and ended the Compose attempt — is now a non-event. Both pods restarted together and the stack came back with dbStatus: online and data intact.
~80% of exam domains, hands-on · target Q2 2027
Cluster Architecture · 15%
install, systemd, kubeconfig
Workloads · 20%
Deployments, replicas, probes
Networking · 20%
Ingress, ClusterIP, DNS
Storage · 10%
PV, PVC, reclaim, securityContext
Troubleshooting · 30%
logs, exec, describe, events
Why the failures section matters Troubleshooting is the heavyweight at 30% — and it is the one domain that cannot be studied from documentation. Every item above was a live debugging exercise, not a lab.

Real migration experience — seven production services with live data, not a tutorial cluster.
Pattern recognition — services deliberately chosen to de-risk distinct migration shapes before critical ones were touched.
Risk management — parallel running, verification-before-decommission, instant rollback, Retain volumes.
Honest documentation — the failures are published, including one that appeared to lose data.
Modern private-cluster networking — how real corporate meshes work, with zero public attack surface.
Cost engineering — production-grade patterns at $0/month.

"I migrated production services off Docker Compose onto a Tailscale-private k3s cluster — zero public ports, Tailscale Ingress instead of sidecar containers, hostPath persistent volumes with verified data migrations, and a rollback path for every step. Seven services and every core migration pattern are done and documented, including the failures. $0/month, and it doubles as my CKA lab."
Next
critical services
Then
authentik · n8n · qdrant
Later
2nd node + HA storage
Phase 1 The original Week 2 admin-UI batch (homarr, portainer, pocket-id, glances, redisinsight) is dropped — those services no longer exist on the host. Remaining work is the critical tier: authentik, open_notebook, n8n, qdrant.
Case study · Project Level Up Javid · started 2026-08-17 · updated 2026-09-25
8 of 19 migrated + 1 net-new service · every core pattern proven · $0/month
Deployed via Cloudflare Pages · Built with Hermes Agent