Files
home-docker/.claude/skills/home-k3s-deploy/SKILL.md
T
2026-03-17 10:27:32 +11:00

9.7 KiB

name, description, compatibility, metadata
name description compatibility metadata
home-k3s-deploy Deploy and manage apps on junv's home K3s cluster at 192.168.1.2 (server-3). Use this skill when creating new Kubernetes manifests, adding services, configuring ingress/TLS, managing storage, or modifying existing deployments in this homelab cluster. Covers namespace conventions, storage patterns, ingress annotations, TLS, OAuth2 auth, node affinity, and apply workflow. Requires kubectl configured for 192.168.1.2; manifests live in /Users/junv/code/home-docker
author cluster domain
junv server-3 (192.168.1.2) *.junv.cc

Home K3s Deployment Skill

Cluster Overview

  • Node: server-3 at 192.168.1.2 (single-node K3s cluster)
  • K3s version: v1.33.3+k3s1, Ubuntu 24.04.3
  • SSH: ssh -p 22422 junv@192.168.1.2 (Ed25519 key)
  • Primary domain: *.junv.cc (Cloudflare DNS + cert-manager Let's Encrypt)
  • Local network: 192.168.1.0/24 (trusted, never block)
  • Kubeconfig: scp -P 22422 junv@192.168.1.2:/etc/rancher/k3s/k3s.yaml ~/.kube/config && sed -i '' 's/127.0.0.1/192.168.1.2/g' ~/.kube/config

Namespace Organization

Namespace Purpose
home-apps Home automation, productivity, personal tools
media Media servers (alist, static files)
db Databases: Redis (db ns), Qdrant vector DB
ai AI/ML workloads
argocd GitOps deployment (ArgoCD)
ingress-nginx NGINX Ingress Controller
cert-manager TLS certificate management
crowdsec Security monitoring
default Prometheus/Grafana monitoring

Repository File Layout

New manifests go in the directory matching the app's namespace:

home-docker/
├── home-apps/    → namespace: home-apps
├── media/        → namespace: media
├── db/           → namespace: db
├── ai/           → namespace: ai
├── adhoc-config/ → one-off PVs, config patches
├── terraform/    → infra-as-code (prefer for cluster-level changes)
└── archive/      → deprecated configs (do not edit)

Adding a New App: Step-by-Step

  1. Choose the correct directory and namespace (see table above).
  2. Create a single YAML file {app-name}.yaml with all resources separated by ---.
  3. Resource order in the file: PV → PVC → Deployment → Service → Ingress.
  4. If local storage is needed, also create adhoc-config/{app-name}-local-pv.yaml.
  5. Apply: kubectl apply -f {file}.yaml (or kubectl apply -f adhoc-config/{app-name}-local-pv.yaml first).

Storage Patterns

Local Storage (most common for new apps)

Use local-storage storageClass with node affinity pinned to server-3. Data path: /mnt/k8s/{app-name}/.

Create the directory first on the node: ssh -p 22422 junv@192.168.1.2 "sudo mkdir -p /mnt/k8s/{app-name}"

# adhoc-config/{app-name}-local-pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: {app-name}-local-pv
  labels:
    type: local
    name: {app-name}
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:
    path: /mnt/k8s/{app-name}
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - server-3
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  namespace: {namespace}
  name: {app-name}-local-pvc
spec:
  volumeName: {app-name}-local-pv
  storageClassName: "local-storage"
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi

NFS Storage (for large or shared data)

NFS server: 192.168.1.5, base path: /fs/1000/nfs/k8s/. Use storageClassName: "" (empty) with no volumeName binding.

apiVersion: v1
kind: PersistentVolume
metadata:
  name: {app-name}-nfs-pv
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  nfs:
    server: 192.168.1.5
    path: "/fs/1000/nfs/k8s/{app-name}"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  namespace: {namespace}
  name: {app-name}-nfs-pvc
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: ""
  resources:
    requests:
      storage: 10Gi

Deployment Pattern

Always set revisionHistoryLimit: 2. Pin the timezone. For images from Docker Hub that may be rate-limited, use mirror.gcr.io/ prefix (e.g., mirror.gcr.io/library/nginx:latest).

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {app-name}
  namespace: {namespace}
  labels:
    app: {app-name}
spec:
  replicas: 1
  revisionHistoryLimit: 2
  selector:
    matchLabels:
      app: {app-name}
  template:
    metadata:
      labels:
        app: {app-name}
    spec:
      nodeSelector:
        kubernetes.io/hostname: server-3
      # If pulling from GitHub Container Registry:
      imagePullSecrets:
        - name: github-image-pull-secret
      containers:
        - name: {app-name}
          image: mirror.gcr.io/{org}/{image}:{tag}
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: {port}
          env:
            - name: TZ
              value: Australia/Melbourne
          volumeMounts:
            - name: data
              mountPath: /data
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: {app-name}-local-pvc

Service Pattern

apiVersion: v1
kind: Service
metadata:
  name: {app-name}
  namespace: {namespace}
spec:
  type: ClusterIP
  selector:
    app: {app-name}
  ports:
    - name: web
      port: 80
      targetPort: {container-port}
      protocol: TCP

For direct LAN access (no ingress), use LoadBalancer type with a static MetalLB IP:

spec:
  type: LoadBalancer
  annotations:
    metallb.universe.tf/loadBalancerIPs: 192.168.1.2XX

Ingress Patterns

Always use ingressClassName: nginx and cert-manager.io/cluster-issuer: "letsencrypt-prod" for TLS.

Public HTTPS (anyone on the internet)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {app-name}-ingress
  namespace: {namespace}
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - {app-name}.junv.cc
    secretName: {app-name}-tls
  rules:
  - host: {app-name}.junv.cc
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: {app-name}
            port:
              number: 80

LAN-Only (IP whitelisted to home network)

Add the whitelist annotation — no OAuth needed:

annotations:
  cert-manager.io/cluster-issuer: "letsencrypt-prod"
  nginx.ingress.kubernetes.io/whitelist-source-range: "192.168.1.0/24"

OAuth2-Protected (via Pocket ID / passkey-auth)

Add these two annotations to require login via pass.junv.cc:

annotations:
  cert-manager.io/cluster-issuer: "letsencrypt-prod"
  nginx.ingress.kubernetes.io/auth-url: "https://pass.junv.cc/oauth2/auth"
  nginx.ingress.kubernetes.io/auth-signin: "https://pass.junv.cc/oauth2/start?rd=https://$host$escaped_request_uri"

Existing Secrets (reference, do not recreate)

Secret Name Namespace Keys Purpose
postgres-credential home-apps username, password PostgreSQL at 192.168.1.2:5432
qdrant-api-key db key Qdrant vector DB API key
github-image-pull-secret home-apps Pull from ghcr.io

Use secrets via secretKeyRef:

env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: postgres-credential
        key: password

Infrastructure Services (in-cluster)

Service Address Notes
PostgreSQL 192.168.1.2:5432 Host-level install
Redis 192.168.1.2:6379 Bitnami Helm chart, db namespace
Qdrant qdrant.db.svc.cluster.local:6333 Vector DB, db namespace
ArgoCD https://argo.junv.cc GitOps; add apps via ArgoCD UI or Application CR

Common Conventions

  • Timezone: Always set TZ: Australia/Melbourne
  • Node pin: nodeSelector: kubernetes.io/hostname: server-3 for stateful workloads
  • Image mirror: Prefix Docker Hub images with mirror.gcr.io/ to avoid rate limits
  • Ingress class: ingressClassName: nginx (not the legacy annotation)
  • TLS issuer: letsencrypt-prod ClusterIssuer (email: me@junv.cc)
  • Namespace on PVC/Ingress: Always specify namespace: — it's easy to forget
  • Namespace on PV: PersistentVolumes are cluster-scoped — do NOT add namespace:
  • revisionHistoryLimit: 2: Keep rollout history small

Apply Workflow

# Apply a new manifest
kubectl apply -f home-apps/{app-name}.yaml

# Apply a local-PV config first (before the main manifest)
kubectl apply -f adhoc-config/{app-name}-local-pv.yaml
kubectl apply -f home-apps/{app-name}.yaml

# Check status
kubectl get pods -n {namespace}
kubectl describe pod -n {namespace} {pod-name}
kubectl logs -n {namespace} -l app={app-name} --tail=50

# Restart a deployment
kubectl rollout restart deployment/{app-name} -n {namespace}

# Get events (useful for debugging PVC bind issues)
kubectl get events -n {namespace} --sort-by='.lastTimestamp'

Do NOT

  • Delete files without user confirmation
  • Use iptables integration in CrowdSec (breaks kubectl access to port 6443)
  • Use inotify for container log acquisition in CrowdSec (use poll_without_inotify: true)
  • Hardcode secrets in manifests — always use secretKeyRef
  • Block 192.168.1.0/24 — it's the trusted local network
  • Use SSH port 22 — the correct port is 22422