curl -sfL https://get.k3s.io | sh -s - server \
--disable traefik \
--disable servicelb \
--disable local-storage \
--node-ip $(ip -4 addr show tailscale0 | grep -oP '(?<=inet\s)\d+(\.\d+){3}') \
--advertise-address $(ip -4 addr show tailscale0 | grep -oP '(?<=inet\s)\d+(\.\d+){3}') \
--flannel-iface tailscale0 \
--tls-san <node tailscale name> \
--tls-san <node tailscale domain name> \
--write-kubeconfig-mode 644Fetch the k3s kubeconfig from the server and update it with your server's IP:
# Set your k3s server details
export K3S_SERVER_IP=192.168.1.x # Replace with your actual server IP
export K3S_USER=ubuntu # Replace with your SSH user
# Create .kube directory if it doesn't exist
mkdir -p ~/.kube
# Copy using SCP from server's home directory
scp ${K3S_USER}@${K3S_SERVER_IP}:/etc/rancher/k3s/k3s.yaml ~/.kube/config
# Replace localhost with server IP
sed -i "s/127.0.0.1/$K3S_SERVER_IP/g" ~/.kube/configVerify connection and dual-stack:
# Verify kubectl works
kubectl get nodes
# Verify dual-stack is enabled
kubectl get nodes -o jsonpath='{.items[*].spec.podCIDRs}'
# Should show both IPv4 and IPv6 rangesCreate Bitwarden auth token sealed secret:
# Prerequisites:
# 1. Sealed Secrets controller installed (see Helm charts above)
# 2. Get machine account token from: https://vault.bitwarden.com/#/settings/organizations/<org-id>/machine-accounts
# 3. Store it in password manager item named: "homelab-machine-account-auth-token"
# (in the notes field)
./k8s/create-bw-auth-token.sh
# This creates k8s/secrets/bw-auth-token-sealed.yaml (encrypted, safe to commit!)
# Commit it to gitNote: k3s comes with Traefik pre-installed. We'll use the built-in Traefik as the ingress controller.
cert-manager (Certificate Management):
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.19.2 \
--set installCRDs=trueBitwarden Secrets Manager Operator:
helm repo add bitwarden https://charts.bitwarden.com
helm repo update
helm install sm-operator bitwarden/sm-operator \
--namespace bitwarden \
--create-namespaceMetalLB (LoadBalancer):
helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm install metallb metallb/metallb \
--namespace metallb-system \
--create-namespaceSealed Secrets (Encrypted Secrets):
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm repo update
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-systemInstall the kubeseal CLI:
# Download latest release
KUBESEAL_VERSION=$(curl -s https://api.github.com/repos/bitnami-labs/sealed-secrets/releases/latest | grep '"tag_name"' | cut -d'"' -f4 | sed 's/v//')
curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v${KUBESEAL_VERSION}/kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz"
tar -xvzf kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
rm kubeseal kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gzHeadlamp (Kubernetes Dashboard):
helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/
helm repo update
helm install headlamp headlamp/headlamp \
--namespace kube-system \
--set extraArgs="{-in-cluster}"Create a long-lived token for authentication (1 year):
kubectl create token headlamp --namespace kube-system --duration=8760hAccess at: https://headlamp.k8s.karkki.org - paste the token when prompted (stored in browser).
Authentik (Identity Provider):
helm repo add authentik https://charts.goauthentik.io
helm repo update
# Generate secrets
SECRET_KEY=$(openssl rand -base64 45)
PG_PASSWORD=$(openssl rand -base64 32)
helm install authentik authentik/authentik \
--namespace apps \
--set authentik.secret_key="$SECRET_KEY" \
--set authentik.postgresql.password="$PG_PASSWORD" \
--set postgresql.enabled=true \
--set postgresql.auth.password="$PG_PASSWORD" \
--set redis.enabled=true \
--set server.ingress.enabled=falseInitial setup: https://auth.k8s.karkki.org/if/flow/initial-setup/
Planka (Kanban Board):
helm repo add planka https://plankanban.github.io/planka
helm repo update
# Generate a secret key
SECRET_KEY=$(openssl rand -base64 45)
helm install planka planka/planka \
--namespace apps \
--set secretkey="$SECRET_KEY" \
--set baseUrl="https://planka.k8s.karkki.org" \
--set postgresql.enabled=true \
--set persistence.enabled=true \
--set persistence.size=5GiTo enable SSO with Authentik:
- In Authentik, create an OAuth2/OpenID Provider with redirect URI:
https://planka.k8s.karkki.org/oidc-callback - Create an Application linked to the provider
- Update Planka with OIDC settings:
helm upgrade planka planka/planka \
--namespace apps \
--reuse-values \
--set oidc.enabled=true \
--set oidc.issuerUrl="https://auth.k8s.karkki.org/application/o/planka/" \
--set oidc.clientId="<client-id>" \
--set oidc.clientSecret="<client-secret>" \
--set 'extraEnv[0].name=OIDC_ENFORCED' \
--set 'extraEnv[0].value=true'Access at: https://planka.k8s.karkki.org
Deploy resources:
# Apply all k8s/ resources at once (recommended)
kubectl apply -k k8s/
# Or apply by namespace:
kubectl apply -k k8s/apps/
kubectl apply -k k8s/bitwarden/
kubectl apply -k k8s/cert-manager/
kubectl apply -k k8s/kube-system/
kubectl apply -k k8s/metallb-system/
kubectl apply -f k8s/cluster-issuer.yamlInstall all components at once:
kubectl apply -k k8s/Or install individually as documented below.
Location: k8s/apps/cloudflare.yaml (BitwardenSecret resources)
Syncs secrets from Bitwarden Secrets Manager to Kubernetes Secrets using the BitwardenSecret CRD.
Prerequisites:
- Bitwarden Secrets Manager Operator installed (Helm chart)
- Machine account token
Create auth token secret:
kubectl create secret generic bw-auth-token \
--from-literal=token='your-machine-account-token' \
-n appsUsage example:
apiVersion: k8s.bitwarden.com/v1
kind: BitwardenSecret
metadata:
name: cloudflare
namespace: apps
spec:
organizationId: "your-org-id"
secretName: cloudflare
map:
- bwSecretId: "secret-id" # pragma: allowlist secret
secretKeyName: api-token
authToken:
secretName: bw-auth-token
secretKey: tokenLocation: k8s/apps/cloudflare.yaml
Namespace: apps
Automated Dynamic DNS updater that runs every 5 minutes to keep Cloudflare DNS records updated with current public IP.
Prerequisites:
- Bitwarden secrets configured with:
- Cloudflare API token (DNS edit permissions)
- Domain name to update
Installation:
kubectl apply -k k8s/apps/Verify:
kubectl get cronjob -n apps cloudflare-ddns
kubectl logs -n apps -l job-name=cloudflare-ddns-<latest>Location: k8s/cluster-issuer.yaml
ClusterIssuer for automatic Let's Encrypt certificates via Cloudflare DNS-01 challenge.
Prerequisites:
- cert-manager installed (via Flux)
- Cloudflare API key in
cloudflaresecret
Installation:
kubectl apply -f k8s/cluster-issuer.yamlUsage in Ingress:
annotations:
cert-manager.io/cluster-issuer: cloudflare-issuerLocation: k8s/kube-system/coredns.yaml
Namespace: kube-system
Custom CoreDNS deployment for external DNS server with MetalLB LoadBalancer.
Configuration:
- Service IP:
192.168.1.201(MetalLB) - DNS Records:
*.k8s.karkki.org→192.168.1.200(Traefik)ridge.karkki.org→192.168.1.125
- Recursive DNS forwarding for other domains
Installation:
kubectl apply -k k8s/kube-system/Verify:
nslookup k8s.karkki.org 192.168.1.201
nslookup test.k8s.karkki.org 192.168.1.201Issue: Pods using cluster DNS (10.43.0.10) resolve *.k8s.karkki.org via public DNS, which returns the public IP. NAT hairpinning fails, breaking internal OIDC/service communication.
Solution: Configure k3s CoreDNS to forward to custom CoreDNS:
kubectl apply -f - <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns-custom
namespace: kube-system
data:
custom-forward.override: |
forward . 192.168.1.201
EOF
kubectl rollout restart deployment coredns -n kube-systemThis makes all pods resolve *.k8s.karkki.org to internal IPs (192.168.1.200).
Issue: systemd-resolved's stub listener (127.0.0.53:53) refuses connections due to k3s networking conflicts, preventing containerd image pulls.
Solution: Use upstream DNS directly instead of stub resolver.
On each k3s node:
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.confWhy this is safe:
- Node DNS: Used by host (containerd, kubelet)
- Cluster DNS (10.43.0.10): Used by pods
- Completely separate systems
Location: k8s/metallb-system/metallb-ip-pool.yaml
Namespace: metallb-system
IP address pool configuration for MetalLB LoadBalancer.
Pools:
metallb-ipv4-pool: 192.168.1.200-192.168.1.220metallb-dual-stack-pool: 192.168.1.221-192.168.1.230 + IPv6
Current Assignments:
- Traefik: 192.168.1.200
- CoreDNS: 192.168.1.201
Installation:
kubectl apply -k k8s/metallb-system/Location: k8s/secrets/ (Component)
Sealed Secrets encrypts Kubernetes secrets so they can be safely committed to git. The Sealed Secrets controller decrypts them in-cluster.
How it works:
- The controller generates an RSA key pair in the cluster
kubesealencrypts secrets using the cluster's public key- Only the controller can decrypt (using the private key)
- SealedSecrets are safe to commit to version control
Create a sealed secret manually:
# Create a regular secret (dry-run)
kubectl create secret generic my-secret \
--from-literal=key=value \
--dry-run=client -o yaml | \
kubeseal \
--controller-name=sealed-secrets \
--controller-namespace=kube-system \
--scope cluster-wide \
--format yaml > my-secret-sealed.yamlScopes:
strict(default): Sealed to specific name and namespacenamespace-wide: Can be renamed within the namespacecluster-wide: Can be deployed to any namespace with any name
Location: k8s/kube-system/traefik.yaml
Namespace: kube-system
Middlewares and IngressRoutes for Traefik dashboard.
Includes:
- Basic auth middleware (credentials from Bitwarden)
- IP whitelist (192.168.1.0/24)
- Dashboard IngressRoute
Installation:
kubectl apply -k k8s/kube-system/Access: http://192.168.1.200/dashboard/
-
Drain the node (evict workloads):
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
-
On the node being removed - stop and uninstall k3s server:
sudo systemctl stop k3s sudo /usr/local/bin/k3s-uninstall.sh
-
Delete the node from cluster:
kubectl delete node <node-name>
-
Rejoin as worker using ansible:
ansible-playbook -i ansible/inventory.yml ansible/playbook.yml --limit <node-name> -t k3s_agents
Or manually:
# Get token from an existing control plane node sudo cat /var/lib/rancher/k3s/server/node-token # On the node, install as agent curl -sfL https://get.k3s.io | K3S_TOKEN=<token> sh -s - agent \ --server https://<control-plane-ip>:6443 \ --node-ip <tailscale-ip> \ --flannel-iface tailscale0 \ --node-label karkki.org/cloud-provider=aws
-
Label the node (if not set during install):
kubectl label node <node-name> karkki.org/cloud-provider=aws
-
Drain the node:
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
-
Delete from cluster:
kubectl delete node <node-name>
-
On the node - uninstall k3s agent:
sudo /usr/local/bin/k3s-agent-uninstall.sh