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.
Migration Progress
8 of 19 services on k3s + 1 net-new
·
updated 2026-09-25
42% COMPLETE
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.
Architecture
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.
Milestones — 8 migrations + 1 net-new
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.
Memory
64Mi req / 256Mi lim
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.
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.
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.
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.
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.
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.
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.
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.
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.
The Headline Win — sidecar elimination
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
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.
What Actually Went Wrong
!
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.
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
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.
CKA Exam Alignment
~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.
Why This Works as a Portfolio Piece
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."
What's Next
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.