Skip to content

Tech

技术笔记与系列 — notes, experiments, and deep dives.

Boring Kubernetes: A Real Front Door with Traefik

In Boring Kubernetes I migrated 23 services from Docker Compose to a single-node K3s box, and ended the post with a smug list of things I didn't need. One of them was:

No fancy ingress (Traefik/Nginx) — Tailscale + NodePort is enough.

Six months later, I've retired every NodePort and put a Traefik ingress in front of every interactive app. This is the story of why "enough" stopped being enough, and how the fix turned out to be code that was already running on the cluster.

The itch: NodePort was fine, until it wasn't

The dual-access networking from last time worked. Tailscale Subnet Router for remote access via *.svc.cluster.local, NodePort for fast local streaming. But two annoyances compounded.

Annoyance 1: NodePort is ugly. Every service meant remembering a random high port:

http://192.168.1.100:30896  # Jellyfin — was it 896 or 968?
http://192.168.1.100:30453  # Navidrome
http://192.168.1.100:30500  # Kavita

No TLS, no names, no bookmarks that survive a service redeploy. Fine for a media app you open from a TV once. Miserable for a dashboard you open ten times a day.

Annoyance 2: chatty HTTP over the subnet router felt sluggish. The subnet router is great for what it is — raw TCP and low-volume admin access. But every packet to grafana.harus-infrastructure.svc.cluster.local took the router hop: client → Tailscale → subnet-router pod → CoreDNS → service. For a bulk postgres:5432 connection you never notice. For an interactive app firing dozens of little XHRs per page, that hop adds up into a spinny, laggy feel even on the same WiFi.

I wanted one thing: type grafana.h.azusachino.icu into any device on my tailnet, get a green padlock, and have it feel like localhost.

Anatomy of the slow path

Why did grafana.harus-infrastructure.svc.cluster.local feel sluggish when a raw ping to the node was ~2ms? Because both the name and the address it resolves to are virtual cluster objects that only exist inside the node — and the only way a tailnet device can reach them is by routing every packet through the single subnet-router pod.

Walk one dashboard click through the old path:

flowchart LR
  B["Browser<br/>(tailnet device)"]
  WG["WireGuard<br/>tunnel"]
  SRP["subnet-router pod<br/>single replica<br/>routes + SNATs 10.43/16<br/>into the cluster"]
  KP["kube-proxy<br/>ClusterIP → pod DNAT"]
  G["grafana pod<br/>10.42.x.y:3000"]
  CD["CoreDNS 10.43.0.10<br/>(also a ClusterIP)"]

  B -->|"① every request"| WG
  WG -->|"② dozens per page"| SRP
  SRP -->|"③ forward + SNAT"| KP
  KP --> G
  SRP -.->|"DNS crosses the router too"| CD
  G -.->|"response retraces every hop"| B

  classDef pain fill:#c0392b,stroke:#7b241c,stroke-width:2px,color:#fff;
  class SRP pain;

Three things conspire, and none of them show up in a ping:

  1. The destination is a ClusterIP, not a host. grafana resolves to something like 10.43.x.y — a virtual IP that lives only in the node's iptables. To reach it from outside, the subnet router advertises 10.43.0.0/16 over Tailscale, so packets are tunneled to the subnet-router pod, which forwards and SNATs them into the cluster network. That pod is an extra L3 hop and a single replica: every byte to every cluster service funnels through one pod's network stack (even with TS_USERSPACE=false it's a separate netns doing subnet routing, not host networking).
  2. DNS crosses the router too. The split-DNS rule sends *.cluster.local to CoreDNS at 10.43.0.10 — itself a ClusterIP behind the router. So even resolving the name pays the hop tax before the first data packet moves.
  3. Interactive pages multiply it. A postgres:5432 bulk transfer opens one connection and streams — you never feel the hop. A Grafana page fires dozens of small, latency-sensitive requests, and each TCP + TLS handshake is several round-trips before the first byte. Multiply "one extra pod hop + SNAT + kube-proxy DNAT" across all of that and a dashboard that should feel instant has visible lag on every click.

The subnet router isn't broken — it's doing exactly what a router does. It's just the wrong tool for chatty, handshake-heavy HTTP. The fix is to stop making interactive traffic terminate at a virtual ClusterIP behind a forwarding pod.

The realization: the ingress controller was already there

Here's the embarrassing part. When I wrote "no ingress controller," I was wrong. The K3s-bundled Traefik had been running in kube-system the entire time — I'd just never given it a single route.

Correction to my past self: the cluster does have an ingress controller. The bundled Traefik was simply unused (zero routes).

That reframes the whole project. This wasn't "install and operate Traefik." It was "adopt something already running and exposed." No new Deployment, no Helm install, no operator. Just:

  1. Teach DNS to point *.h.azusachino.icu at the node.
  2. Give Traefik a trusted wildcard cert.
  3. Drop an IngressRoute next to each service.

How it's wired: killing the router hop

K3s ships Traefik as a LoadBalancer Service, and ServiceLB (the svclb-traefik DaemonSet) binds host ports 80/443 directly on the node. My node's tailnet IP is 100.89.137.15 — a real tailnet peer, not a ClusterIP. So the data path now terminates on the node's own host networking:

flowchart LR
  B["Browser<br/>(tailnet device)"]
  WG["WireGuard<br/>tunnel"]
  TR["Traefik on the node<br/>host :443 (ServiceLB hostPort)<br/>keep-alive pool to backends"]
  KP["node-local iptables<br/>ClusterIP → pod DNAT"]
  G["grafana pod<br/>10.42.x.y:3000"]

  B -->|"request to 100.89.137.15:443"| WG
  WG -->|"one hop to a real host"| TR
  TR -->|"node-local, reused upstream"| KP
  KP --> G
  G -.->|"response"| B

  classDef good fill:#1e8449,stroke:#145a32,stroke-width:2px,color:#fff;
  class TR good;

Same request, but look at what's gone:

  • The tunnel ends at a real host. 100.89.137.15:443 is the node itself on the tailnet — one WireGuard hop to a genuine peer, no forwarding pod in the middle. The subnet-router pod is entirely off the hot path.
  • The last hop is node-local. From the host, reaching the backend is a single in-node iptables DNAT (ClusterIP → pod) — microseconds, no tunnel, no SNAT through a second netns.
  • Traefik keeps connections warm. It holds a keep-alive pool to each backend, so the per-request handshake cost that hurt most over the router path largely disappears — the browser handshakes once with Traefik over TLS, and Traefik reuses hot upstreams.

The division of labor is now clean:

  • Traefik handles all HTTP/L7 — every interactive web app. There's no raw HTTP over the tailnet anymore.
  • Subnet router stays for DNS — CoreDNS's ClusterIP is still how *.h.azusachino.icu itself resolves — plus raw non-HTTP TCP like postgres:5432. Exactly the traffic that never minded the hop.
  • NodePorts are retired entirely.

DNS: one wildcard to rule them all

I don't want a DNS record per service. I want *.h.azusachino.icu to land on the Traefik entrypoint, and let Traefik sort out routing by Host header.

Two pieces make that work. First, K3s CoreDNS auto-imports custom server blocks from a ConfigMap, so I template the whole subdomain to the node's tailnet IP:

# 01-infrastructure/traefik/coredns-custom.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-custom
  namespace: kube-system
data:
  h-azusachino.server: |
    h.azusachino.icu {
      template IN A {
        answer "{{ .Name }} 60 IN A 100.89.137.15"
      }
      template IN AAAA {
        rcode NOERROR
      }
    }

Second, a Tailscale split-DNS rule (set once in the console) routes h.azusachino.icu → 10.43.0.10, the CoreDNS ClusterIP. Now any tailnet device resolving grafana.h.azusachino.icu gets 100.89.137.15, hits Traefik, and Traefik matches the host.

Yes — that DNS lookup still crosses the subnet router to reach CoreDNS. The difference is it's a once-per-name, 60-second-cached cost, not a per-request one. The data path — the thousands of packets that interactive latency is actually made of — now goes straight to the host. That's the whole trade: keep the cheap thing on the router, move the expensive thing off it.

TLS: real Let's Encrypt certs for tailnet-only hosts

This is the part I'm most pleased with. These hosts are not publicly reachable — they only exist on my tailnet. Normally that rules out Let's Encrypt, because HTTP-01 validation needs a public IP.

DNS-01 doesn't. It proves domain ownership by writing a TXT record via the Cloudflare API, which never requires the host itself to be reachable from the internet. So I get genuine, browser-trusted wildcard certs for hosts that live entirely behind Tailscale. No mkcert, no per-device CA trust, no "your connection is not private" clickthroughs.

I don't deploy my own Traefik to configure this — I deep-merge onto the bundled chart with a HelmChartConfig, which K3s's helm-controller layers on top of the chart's managed values:

# 01-infrastructure/traefik/helmchartconfig.yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    persistence:
      enabled: true
      storageClass: local-path
      size: 128Mi
      path: /data
    # acme.json must be writable by the non-root traefik user (uid/gid 65532)
    podSecurityContext:
      fsGroup: 65532
      fsGroupChangePolicy: "OnRootMismatch"
    env:
      - name: CF_DNS_API_TOKEN
        valueFrom:
          secretKeyRef:
            name: traefik-cf-dns-token
            key: CF_DNS_API_TOKEN
    certificatesResolvers:
      le:
        acme:
          email: [email protected]
          storage: /data/acme.json
          dnsChallenge:
            provider: cloudflare
            resolvers: ["1.1.1.1:53", "8.8.8.8:53"]
    # Default cert for websecure: one LE wildcard, served by SNI to any route.
    tlsStore:
      default:
        defaultGeneratedCert:
          resolver: le
          domain:
            main: h.azusachino.icu
            sans: ["*.h.azusachino.icu"]

Three details worth calling out:

  • acme.json on a local-path PVC. Let's Encrypt rate limits are unforgiving. Persist the cert store so a Traefik restart doesn't re-issue and burn your quota.
  • fsGroup: 65532. Traefik runs as a non-root user; without the right group ownership it can't write acme.json and silently fails to persist certs.
  • defaultGeneratedCert. One wildcard cert serves every route by SNI match. No per-route resolver config — a route just says tls: {} and inherits the padlock.

The Cloudflare token is a SealedSecret (traefik-cf-dns-token), scoped to Zone · DNS · Edit on the zone — the minimum DNS-01 needs.

Routing a service is now three lines of intent

With the plumbing done, adding a service is trivial. Drop an IngressRoute in the service's own directory (same namespace as its Service), listing both entrypoints and tls: {}:

# 01-infrastructure/grafana/ingressroute.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: grafana
  namespace: harus-infrastructure
spec:
  entryPoints: [web, websecure]
  routes:
    - match: Host(`grafana.h.azusachino.icu`)
      kind: Rule
      services:
        - name: grafana
          port: 80
  tls: {} # inherits the LE wildcard *.h.azusachino.icu

Apply it, and you can test the route before DNS has propagated by faking the Host header:

curl -H "Host: grafana.h.azusachino.icu" http://100.89.137.15/

That's the entire per-service cost now. Two dozen apps — Grafana, Immich, Jellyfin, Vaultwarden, Karakeep, n8n — all follow the identical pattern, each host <name>.h.azusachino.icu. Public exposure stays a separate, deliberate concern: only the Cloudflare Tunnel allowlist is reachable off-tailnet.

The gotchas

  • Version is chart-managed. The bundled Traefik tracks the K3s release (currently v3.6.x) and is not in my pinning discipline. A K3s upgrade can move it; I just record the observed version with a note rather than fighting it.
  • Split-DNS is a manual console step. The coredns-custom half is in Git, but the Tailscale h.azusachino.icu → 10.43.0.10 rule lives in the Tailscale admin console. Easy to forget when rebuilding.
  • DNS-01 needs the token in the right namespace. The SealedSecret must land in kube-system (where Traefik runs), not the app namespace.

Was it worth undoing my past self?

The old post's thesis was "boring is good — build it once, let it run." Adopting Traefik didn't betray that; it extended it. I didn't add a moving part — I lit up one that was already there and idle. The result is fewer things to remember, not more:

  • ✅ Every app at a real name: <service>.h.azusachino.icu
  • ✅ Green-padlock TLS everywhere, zero per-device trust, zero public exposure
  • ✅ Interactive apps off the subnet-router data path — no more spinny dashboards
  • ✅ NodePort port-roulette retired
  • ✅ New service = one IngressRoute with tls: {}

The subnet router didn't go away — and its most load-bearing job now is the one that's easiest to miss: DNS. Even the shiny Traefik path can't resolve *.h.azusachino.icu without it, because those names answer at CoreDNS's ClusterIP, reachable only through the router. Beyond that it still carries raw, non-HTTP TCP (postgres:5432 and friends) from the tailnet. What it stopped carrying is interactive HTTP — that's all Traefik now. It just stopped being the tool for everything. That's the real lesson of a boring homelab: it's not that you never change it. It's that when you do, you reach for the thing already on the cluster before you install a new one.

What's next

Traefik closed the ingress gap. Poking at the cluster afterward, the honest list of what's still missing is short — and, keeping with the boring rule, each item either lights up something already adoptable or plugs into a tool I already run (Grafana, Prometheus, the Git → ArgoCD loop). No new control planes.

Roughly in order of leverage:

  1. Alerting — Alertmanager → ntfy/Discord. I have all these metrics and still no way to be told when Immich OOMs or a PVC fills. Highest-return single addition.
  2. Logs — Loki + Grafana Alloy. The missing observability pillar. Grafana already runs, so Loki is just a datasource: click a metric spike, jump to the correlated logs. (Alloy, not the now-deprecated Promtail.)
  3. Automated updates — Renovate. I run GitOps but still bump image tags by hand — the "update anxiety" from the first post, still not fully dead. Renovate opens PRs, ArgoCD syncs. This finally closes that loop.
  4. External uptime — Gatus. Prometheus scrapes internals; it won't tell me a route returns 502 from the outside. Declarative YAML, so it fits the GitOps repo.

And the one deliberate fork off the "boring" path, filed under someday, for the learning, not because it hurts: going multi-node HA (three servers for real etcd quorum — two is worse than one) with Longhorn replacing local-path so storage survives a node dying. That one breaks the single-node thesis this whole series is built on, so it stays firmly at the bottom.

The full tiered version — with a dataflow diagram of where each piece plugs in — lives as a living ROADMAP.md in the cluster repo, so the plan is versioned next to the manifests instead of trapped in a blog post.


TL;DR: Retired NodePorts and put the K3s-bundled Traefik (previously running with zero routes) in front of every HTTP app. Wildcard DNS via coredns-custom + Tailscale split-DNS, real Let's Encrypt certs for tailnet-only hosts via Cloudflare DNS-01, one default wildcard cert inherited by every route with tls: {}. No subnet-router hop, no port-roulette, green padlocks everywhere. Adopting beats installing.

Boring Kubernetes

I recently migrated my home server (23 services including Immich, Vaultwarden, Jellyfin, and a complete monitoring stack) from Docker Compose to k3s. This is the story of building a "boring but reliable" Kubernetes homelab that prioritizes privacy, simplicity, and actual production readiness.

The standard advice for homelabs is "stick to Docker Compose." While valid for simple setups, I wanted the declarative state management, proper resource limits, and zero-downtime updates of Kubernetes—without the operational nightmare of a full multi-node cluster.

Here's how I built a single-node K3s setup that handles 28 pods across 7 namespaces, with zero open ports, dual-access networking, and a 3-layer backup strategy.

The Problem: Docker Compose Growing Pains

My original Docker Compose setup worked, but had issues:

  • Resource chaos: No memory limits meant Immich could OOM the entire host
  • Update anxiety: docker-compose down && up meant downtime for all services
  • Network complexity: Exposing 23 services meant managing 23 different ports or a complex reverse proxy
  • Backup uncertainty: File-based backups of database directories felt fragile
  • No rollback: Breaking changes meant scrambling through git history

I didn't need Kubernetes for the sake of Kubernetes. I needed better primitives for managing a production-like homelab.

The Architecture: Single Node, Multi-Layer Design

Hardware: Generic Mini PC Specs: 8 cores, 32GB RAM, 1TB NVMe OS: Ubuntu 22.04 LTS Orchestrator: K3s v1.28 (lightweight Kubernetes) Storage: Default local-path-provisioner (no Longhorn/Ceph complexity)

1. The Network Stack: Dual-Access with Tailscale

This was the most interesting part of the setup. I needed:

  • Low-latency home access for 4K streaming (Jellyfin, Navidrome)
  • Secure remote access from anywhere (phone, laptop, office)
  • Zero public exposure (no open ports on router)
Initial Attempt: Tailscale Operator (Failed)

I started with the Tailscale Kubernetes Operator, which creates a LoadBalancer service for each exposed app. For 23 services, that meant 23 proxy pods.

The problems:

  • High memory overhead (~100MB per proxy pod = 2.3GB wasted)
  • High latency (~20-50ms added) even on same WiFi
  • Complex resource management

The pivot: I deleted the operator entirely.

Current Solution: Tailscale Subnet Router + Dual Access

For remote access (Tailscale Subnet Router):

Single deployment that advertises K3s network CIDRs to Tailscale:

  • Pod CIDR: 10.42.0.0/16
  • Service CIDR: 10.43.0.0/16
# Single pod (~100MB RAM total)
env:
  - name: TS_ROUTES
    value: "10.42.0.0/16,10.43.0.0/16"
  - name: TS_USERSPACE
    value: "false"

Result: Access any service via its Kubernetes DNS name:

http://jellyfin.harus-media.svc.cluster.local
http://grafana.harus-infrastructure.svc.cluster.local

For home network access (NodePort):

For high-bandwidth services (4K video, lossless audio), I added NodePort services:

# Direct LAN access on home WiFi
http://192.168.1.100:30896  # Jellyfin
http://192.168.1.100:30453  # Navidrome
http://192.168.1.100:30500  # Kavita
http://192.168.1.100:30283  # Immich

Performance comparison:

  • NodePort (home): ~2ms latency, full gigabit bandwidth
  • Tailscale (home): ~5-10ms latency (Tailscale detects LAN peers)
  • Tailscale (remote): ~30-50ms latency, WAN limited

The beauty: Both work simultaneously. Jellyfin app has two server URLs configured—it automatically uses NodePort at home and Tailscale when away.

2. Namespace Design: 5-Layer Architecture

I organized services into logical layers:

00-foundation/          # Namespaces, Tailscale Subnet Router
01-infrastructure/      # Prometheus, Grafana, Node Exporter, Kite Dashboard
02-middleware/          # PostgreSQL, MariaDB, Valkey (shared databases)
03-core/               # Vaultwarden, Immich, Readeck, Lychee, CouchDB, Harus Blog
04-media/              # Jellyfin, Navidrome, Kavita, File Browser
05-tools/              # N8N, Harus Bot

Why this matters:

Cross-namespace communication uses FQDNs:

env:
  - name: DB_HOSTNAME
    value: "postgres.harus-middleware.svc.cluster.local"
  - name: DB_PORT
    value: "5432"

Critical gotcha: Secrets don't propagate across namespaces. If a service in harus-core needs PostgreSQL credentials, you must create the secret in harus-core:

make secret ENV=postgres.env NAME=postgres-secret NS=harus-core

3. Secrets Management: Make it Simple

I tried Bitnami Sealed Secrets initially. It's great for GitOps teams, but added unnecessary complexity for a single-user cluster.

The pivot: I stripped it out and used a Makefile wrapper around native Kubernetes Secrets.

Current workflow:

  1. Keep .env files locally (gitignored):

    # postgres.env
    POSTGRES_PASSWORD=super-secret-password
    POSTGRES_USER=postgres
    
  2. Generate secrets via make:

    make secret ENV=postgres.env NAME=postgres-secret NS=harus-middleware
    
  3. Reference in manifests:

    env:
      - name: POSTGRES_PASSWORD
        valueFrom:
          secretKeyRef:
            name: postgres-secret
            key: POSTGRES_PASSWORD
    

Why it works:

  • Native Kubernetes primitives (no custom controllers)
  • Simple to understand and debug
  • Lost the secret? Regenerate from .env file
  • No master key to lose (Sealed Secrets problem)

4. Storage Strategy: Simple but Safe

I avoided distributed storage (Ceph, Longhorn) and stuck with K3s's default local-path-provisioner.

Three storage types:

  1. Critical data (PersistentVolume + hostPath):

    # /mnt/harus_data/vaultwarden - survives pod restarts
    hostPath:
      path: /mnt/harus_data/vaultwarden
      type: DirectoryOrCreate
    
  2. Ephemeral/cache (local-path StorageClass):

    # Auto-provisioned at /var/lib/rancher/k3s/storage/pvc-{UUID}
    storageClassName: local-path
    
  3. Read-only media (hostPath ReadOnly):

    # Large media libraries (movies, music, books)
    hostPath:
      path: /mnt/harus_storage/media
      type: Directory
    

Default storage location:

/var/lib/rancher/k3s/storage/pvc-abc123_harus-core_grafana-pvc/

5. Backup Strategy: 3-Layer Defense

This is where Kubernetes really shines compared to Docker Compose.

Layer 1: K3s Cluster State (NEW!)

K3s has built-in etcd snapshot capabilities:

# Automated every 6 hours
--etcd-snapshot-schedule-cron "0 */6 * * *"
--etcd-snapshot-retention 10

What it backs up:

  • All Kubernetes resources (Deployments, Services, ConfigMaps, Secrets)
  • RBAC configurations
  • PVC metadata (not the data itself)

Why it matters: If the machine dies, I can restore the entire cluster state in ~2 minutes instead of manually reapplying 100+ YAML files.

Automated upload to R2:

# systemd timer uploads snapshots to Cloudflare R2
rclone copy /var/lib/rancher/k3s/server/db/snapshots/ \
  cloudflare-r2:harus-backup/k3s-snapshots/

Storage cost: ~1GB, ~$0.02/month

Layer 2: Application Data (Files)

hako CronJob backs up critical PVCs:

# Daily at 2:00 AM
schedule: "0 2 * * *"
# Backs up: Vaultwarden, Immich photos, CouchDB, etc.

Storage cost: ~100GB, ~$1.50/month

Layer 3: Database Dumps (Consistency)

CronJobs run database dumps for point-in-time recovery:

# PostgreSQL dump (daily 1:30 AM)
pg_dumpall -U postgres | gzip > /backup/postgres-$(date +%Y%m%d).sql.gz

# MariaDB dump
mysqldump --all-databases | gzip > /backup/mariadb-$(date +%Y%m%d).sql.gz

Why separate from Layer 2: Database files might be inconsistent if captured mid-write. SQL dumps guarantee consistency.

Storage cost: ~5GB, ~$0.08/month

Total backup cost: ~$1.60/month on Cloudflare R2

Disaster recovery time: ~45 minutes to 1.5 hours (vs. Docker Compose: "hope you have backups")

6. Public Access: Selective with Cloudflare Tunnel

Most services are private (Tailscale-only), but two need public access:

  • Vaultwarden: Password manager, accessed from browsers without Tailscale
  • CouchDB: Obsidian sync, accessed from mobile app

Solution: Cloudflare Tunnel (formerly Argo Tunnel)

# Sidecar deployment
containers:
  - name: cloudflared
    image: cloudflare/cloudflared:latest
    args:
      - tunnel
      - --no-autoupdate
      - --protocol
      - http2 # CRITICAL: Alpine's env doesn't support QUIC
      - run
      - --token
      - $(TUNNEL_TOKEN)

Why Cloudflare Tunnel:

  • Zero open ports (outbound connection only)
  • Free tier (no cost)
  • Automatic TLS certificates
  • DDoS protection

Configuration gotcha: Use --protocol http2 instead of default QUIC. Alpine Linux's BusyBox env doesn't support the -S flag that QUIC uses, causing cryptic failures.

The Migration Journey: Lessons Learned

What Worked Immediately

  1. Prometheus first: I deployed monitoring BEFORE migrating apps. This let me:

    • Understand baseline resource usage
    • Catch memory leaks early
    • Monitor migration progress
  2. Shared databases: PostgreSQL and MariaDB in harus-middleware namespace

    • Multiple apps share same database instance
    • Saves ~500MB RAM vs. per-app databases
  3. Resource limits everywhere:

    resources:
      requests:
        memory: "128Mi"
        cpu: "100m"
      limits:
        memory: "512Mi"
        cpu: "500m"
    
    • Prevents one app from OOMing the node
    • Kubernetes scheduler makes better decisions

What I Got Wrong (And Fixed)

Problem 1: Tailscale Operator Latency

Initial setup: Tailscale Operator with LoadBalancer services Problem: 20-50ms latency even on home WiFi Root cause: Traffic went through proxy pods instead of direct Fix: Deleted operator, deployed Subnet Router, added NodePort for high-traffic services

Impact: Latency dropped from 50ms to 2ms for local streaming

Problem 2: ReadWriteMany PVC Failures

Initial attempt: Set PVCs to ReadWriteMany (RWX) Error message:

failed to provision volume: NodePath only supports ReadWriteOnce

Fix: Single-node cluster only needs ReadWriteOnce (RWO). Multiple pods on same node can still mount RWO volumes.

Problem 3: OG Image Generation Emoji Failures

When building my Obsidian notes static site with Quartz:

Error:

Failed to emit from plugin `CustomOgImages`: codepoint 31-20e3 not found in map

Root cause: Quartz's OG image generator couldn't render certain emoji (keycap emoji like 1️⃣) Fix: Disabled OG image plugin via sed in build script:

sed -i 's/Plugin\.CustomOgImages/\/\/ Plugin.CustomOgImages/g' quartz.config.ts
Problem 4: Node.js Version Mismatch

Initial image: node:20-alpine Error: Quartz requires Node 22+ Additional issue: Alpine's BusyBox doesn't support -S flag Fix: Switched to node:22-slim (Debian-based)

Resource impact: Increased builder pod from 200m CPU to 1 CPU (for faster builds)

Real-World Examples

Example 1: Obsidian Publishing Pipeline

One of my favorite setups is the automated Obsidian notes publishing:

CouchDB (Obsidian LiveSync)
    ↓ (continuous sync - livesync-bridge)
PVC (markdown files)
    ↓ (daily build - Quartz static site generator)
PVC (compiled HTML)
    ↓ (always serving - Caddy)
http://notes.harus-core.svc.cluster.local

Components:

  1. LiveSync Bridge (Deployment): Continuously syncs notes from CouchDB to PVC
  2. Builder (CronJob): Daily at 5 AM, compiles markdown to static HTML with Quartz
  3. Caddy (Deployment): Serves compiled site

Resources:

  • Bridge: 50m CPU, 128Mi RAM
  • Builder: 1 CPU, 2Gi RAM (runs once daily)
  • Caddy: 10m CPU, 32Mi RAM

Total automation: Write in Obsidian → Sync to CouchDB → Auto-publish to web

Example 2: Immich Photo Stack

Immich requires multiple components. K3s makes this manageable:

# 1. PostgreSQL (dedicated instance)
# 2. Redis (caching)
# 3. Immich Server (main API)
# 4. Immich Machine Learning (face recognition)

In Docker Compose: 4 separate container definitions, manual networking In K3s: Clean separation with proper service discovery:

env:
  - name: DB_HOSTNAME
    value: "immich-postgres.harus-core.svc.cluster.local"
  - name: REDIS_HOSTNAME
    value: "immich-redis.harus-core.svc.cluster.local"

Resource limits ensure fairness:

  • ML container: 2 CPU, 4Gi RAM (intensive but limited)
  • Server: 500m CPU, 2Gi RAM
  • Total: Guaranteed not to exceed 6Gi RAM

Example 3: Shared Database Middleware

Instead of per-app databases, I run shared instances:

PostgreSQL (harus-middleware):

  • Readeck database
  • Harus Bot database
  • Future apps

Benefits:

  • Single backup job for all databases
  • Reduced RAM usage (~500MB vs. 2GB for 4 separate instances)
  • Centralized monitoring

The Stack: 23 Services, 7 Namespaces

Foundation (0)

  • Tailscale Subnet Router

Infrastructure (4)

  • Prometheus (metrics)
  • Grafana (dashboards)
  • Node Exporter (system metrics)
  • Kite Dashboard (lightweight K8s UI)

Middleware (3)

  • PostgreSQL (shared database)
  • MariaDB (legacy apps)
  • Valkey (Redis fork, shared cache)

Core (7)

  • Vaultwarden (password manager)
  • Immich (photo management)
  • Readeck (read-it-later)
  • Lychee (photo gallery)
  • CouchDB (Obsidian sync)
  • Harus Blog (Hugo static site)
  • Harus Obsidian (Quartz notes site)

Media (4)

  • Jellyfin (movies/TV)
  • Navidrome (music streaming)
  • Kavita (ebook/manga reader)
  • File Browser (read-only file server)

Tools (2)

  • N8N (workflow automation)
  • Harus Bot (custom Rust Discord bot)

Total: 28 running pods across 7 namespaces

Why Bother? Docker Compose vs. K3s

Docker Compose is great, but K3s gave me tangible benefits:

1. Zero-Downtime Updates

Docker Compose:

docker-compose down  # Everything stops
docker-compose pull
docker-compose up -d  # Everything starts

K3s:

kubectl set image deployment/immich immich=new-version
# Rolling update: new pod starts → health check passes → old pod terminates
# Service never goes down

2. Resource Guarantees

Docker Compose: Hope for the best K3s: Guaranteed allocations

# Immich ML won't steal RAM from Jellyfin
resources:
  limits:
    memory: "4Gi"

Real impact: Jellyfin no longer stutters when Immich runs face recognition

3. Unified Networking

Docker Compose:

  • Port 8096 → Jellyfin
  • Port 4533 → Navidrome
  • Port 8080 → Grafana
  • (23 ports to remember)

K3s:

  • All services on port 80
  • Access via DNS: service.namespace.svc.cluster.local
  • Tailscale makes it feel like localhost

4. Declarative State

Docker Compose: YAML files + runtime state divergence K3s: Desired state enforcement

# K8s constantly reconciles
kubectl get pods  # Shows actual vs. desired
kubectl describe pod  # Shows events and why

5. Better Observability

Docker Compose: docker stats, hope for the best K3s: Prometheus metrics for everything

  • Per-pod CPU/memory usage
  • Network I/O
  • Restart counts
  • PVC disk usage

Grafana dashboard shows:

  • Which service is using the most RAM
  • When Immich runs ML jobs (CPU spikes)
  • Disk space trends

Performance & Resource Usage

Cluster overhead:

  • K3s system pods: ~400MB RAM
  • Tailscale router: ~100MB RAM
  • Monitoring stack: ~1.5GB RAM
  • Total overhead: ~2GB

Compared to Docker Compose:

  • Similar RAM for apps
  • +2GB for Kubernetes
  • But: Better resource limits prevent OOM
  • But: Monitoring catches issues early

Is it worth 2GB? For 23 services with monitoring, yes.

Costs

Hardware: \(300 Mini PC (one-time) **Electricity:** ~\)5/month (60W idle) Backups: $1.60/month (Cloudflare R2) Tailscale: Free (personal use) Cloudflare Tunnel: Free

Total recurring: ~$6.60/month

Compared to cloud:

  • AWS/GCP equivalent: $200-500/month
  • Savings: $2,300-5,900/year

When NOT to Use This Setup

Stick to Docker Compose if:

  1. You have < 5 services: Kubernetes overhead isn't worth it
  2. You don't care about downtime: docker-compose restart is fine
  3. You're learning Docker: Master one thing at a time
  4. You need multi-arch (ARM + x86): K3s makes this harder
  5. You want simplicity: Docker Compose is genuinely simpler

Consider K3s if:

  1. You have 10+ services: Organization and resource management matter
  2. You need zero downtime: Rolling updates are crucial
  3. You want proper monitoring: Prometheus integration is seamless
  4. You run stateful apps: StatefulSets > Docker volumes
  5. You want to learn Kubernetes: Homelab is the best learning environment

Future Plans

Services I'm considering adding:

  1. Homepage/Homarr: Visual dashboard for all services
  2. Uptime Kuma: Status monitoring with alerts
  3. Paperless-ngx: Document management with OCR
  4. Gitea: Self-hosted git server

Why I haven't added them yet:

  • Cluster is stable
  • Each new service needs testing
  • "Boring" means not constantly tinkering

Conclusion: Boring is Good

This setup is intentionally boring:

  • No service mesh (Istio/Linkerd) - overkill for single node
  • No custom CNI (Calico/Cilium) - default flannel works
  • No distributed storage (Ceph/Longhorn) - local-path is fine
  • No GitOps controller (Flux/ArgoCD) - manual apply works
  • No fancy ingress (Traefik/Nginx) - Tailscale + NodePort is enough

What makes it production-ready:

  • ✅ Automated backups (3 layers)
  • ✅ Monitoring (Prometheus/Grafana)
  • ✅ Resource limits (no OOM kills)
  • ✅ Zero open ports (Tailscale + Cloudflare Tunnel)
  • ✅ Disaster recovery tested
  • ✅ All manifests in Git
  • ✅ Dual-access networking (fast local + secure remote)

The boring parts are features, not bugs. My homelab has been running for weeks without intervention. Services auto-restart on failure. Updates are zero-downtime. Backups run automatically.

That's the whole point: Build it once, let it run.

Resources

My setup (sanitized):

  • GitHub: (link to your repo)
  • Architecture docs: docs/ folder
  • Full deployment status: DEPLOYMENT_STATUS.md

Tools used:

Helpful guides:

  • K3s documentation: Excellent for single-node setups
  • Tailscale subnet routers: Game-changer for homelab networking
  • Kubernetes in Action (book): Best resource for learning K8s concepts

TL;DR: Migrated 23 services from Docker Compose to K3s. Used Tailscale Subnet Router for zero-config remote access, NodePort for fast local streaming, and a 3-layer backup strategy (cluster state + files + databases). Total cost: $6.60/month. Zero open ports. Zero downtime updates. Boring and reliable.

为什么说死亡是万能药

死亡是人类无法避免的终极命运,然而,人们经常将死亡视为“万能药”,因为它可以解决一切问题。在这篇文章中,我们将探讨为什么人们认为死亡是万能药。

首先,死亡可以消除痛苦。生命中的痛苦可以来自于各种各样的因素,比如生病、失去亲人、财产损失等等。当人们遭受痛苦时,他们往往感到绝望和无力。但死亡可以彻底消除这些痛苦,因为一旦人死了,他们就不会再受到痛苦的折磨。

其次,死亡可以消除恐惧。人类天生就有一种对未知事物的恐惧感,尤其是对死亡这样不可避免的命运更是如此。然而,一旦死亡到来,恐惧感就消失了。人们不必再担心死亡,因为他们已经经历过它了。

第三,死亡可以带来解脱。生活中的压力和烦恼常常会让人感到束缚和不堪重负。但是,一旦人死了,他们就能够解脱了,不再受到生活的限制和压迫。

第四,死亡可以带来平等。在生活中,人们之间存在很多不平等,比如财富、权力、地位等。然而,死亡是人类共同的命运,它让所有人都平等了。无论是国王还是平民,都无法逃脱死亡的命运。

最后,死亡可以带来永恒。生命是有限的,但死亡可以让人们进入永恒的状态。人们可以在死后留下自己的遗产,或者他们的事迹会被后人记住。这样,他们的存在就得到了延续。

虽然死亡是一种不可避免的命运,但它却可以解决许多问题。对于那些处于痛苦、恐惧和束缚之中的人们来说,死亡是一种解脱。对于那些受到不平等待遇的人们来说,死亡是一种平等。对于那些渴望永恒存在的人们来说,死亡可以带来永恒。因此,我们可以说,死亡是一种万能药。

~~Generated by ChatGPT~~