A complete single-node Kubernetes cluster, built from scratch using individual components — no K3s, KIND, kubeadm, or any pre-packaged distribution.
Packaged in a minimal Debian-based image via multi-stage build (no package manager at runtime).
┌──────────────────────────────────────────────────┐
│ k8s-one container │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ etcd │ │ apiserver│ │ ctrl-mgr │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │scheduler │ │ kubelet │ │kube-proxy│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │containerd│ │ runc │ │
│ └──────────┘ └──────────┘ │
│ │
│ CNI: Cilium │ DNS: CoreDNS │
│ Storage: local-path-provisioner (hostPath) │
│ Ingress: HAProxy (Host ports 8082/8443) │
│ Metrics: metrics-server (metrics.k8s.io API) │
└──────────────────────────────────────────────────┘
- Quick Start
- Components
- Architecture
- Persistent Volumes
- Configuration
- Secrets
- Cluster Access
- Usage Examples
- Project Structure
- Startup Sequence
- PKI & Certificates
- Networking
- Storage
- Known Issues
- Customization
- Troubleshooting
- Requirements
- Limitations
Copy .env.example to .env and set this machine's Tailscale IP address
(tailscale ip -4):
ARGOCD_VERSION=v3.5.1
TAILSCALE_IP=100.x.y.zDocker Compose loads this file automatically. It is ignored by Git so each environment can choose its Argo CD version and Tailscale address.
# Build
docker compose build
# Start
docker compose up -d
# Follow startup logs (~2-3 min on first run)
docker compose logs -f
# Get kubeconfig
docker cp k8s-one:/etc/kubernetes/admin-external.conf ./kubeconfig
# Use it
export KUBECONFIG=./kubeconfig
kubectl get nodes
kubectl get pods -AExpected output:
NAME STATUS ROLES AGE VERSION
k8s-one Ready <none> 2m v1.36.0
NAMESPACE NAME READY STATUS
kube-system cilium-operator-... 1/1 Running
kube-system cilium-... 1/1 Running
kube-system coredns-... 1/1 Running
local-path-storage local-path-provisioner-... 1/1 Running
haproxy-controller haproxy-kubernetes-ingress-... 1/1 Running
All binaries are downloaded from official sources during build. No pre-packaged components are used.
| Component | Version | Source | Role |
|---|---|---|---|
| kube-apiserver | v1.36.0 | dl.k8s.io | Kubernetes REST API |
| kube-controller-manager | v1.36.0 | dl.k8s.io | Controllers (replication, endpoints, etc.) |
| kube-scheduler | v1.36.0 | dl.k8s.io | Pod scheduling on nodes |
| kubelet | v1.36.0 | dl.k8s.io | Node agent, manages containers |
| kube-proxy | v1.36.0 | dl.k8s.io | Network proxy (iptables mode) |
| kubectl | v1.36.0 | dl.k8s.io | CLI for cluster interaction |
| etcd | v3.5.21 | github.com/etcd-io | Cluster key-value store |
| containerd | 1.7.27 | github.com/containerd | Container runtime (CRI) |
| runc | v1.2.6 | github.com/opencontainers | OCI runtime |
| CNI plugins | v1.6.2 | github.com/containernetworking | Base network plugins |
| Cilium | v1.19.5 | github.com/cilium/cilium | CNI — networking + network policy (eBPF) |
| Cilium CLI | v0.19.4 | github.com/cilium/cilium-cli | Cilium installation & management |
| CoreDNS | v1.12.0 | registry.k8s.io | Cluster DNS |
| local-path-provisioner | v0.0.30 | github.com/rancher/local-path-provisioner | Dynamic hostPath provisioning (default StorageClass) |
| HAProxy Ingress | pinned by digest | haproxytech/kubernetes-ingress | Ingress Controller (HAProxy 3.2.21) |
| metrics-server | v0.9.0 (pinned by digest) | registry.k8s.io | metrics.k8s.io API — kubectl top / HPA |
┌─────────────────────────────────────┐
│ Stage 1: Builder (alpine:3.21) │
│ │
│ • curl, tar, gzip │
│ • Downloads all binaries │
│ • Discarded in final image │
└──────────────┬──────────────────────┘
│ COPY binaries
▼
┌─────────────────────────────────────┐
│ Stage 2: Runtime (debian:bookworm) │
│ │
│ • bash, openssl, iptables │
│ • socat, conntrack │
│ • apt/dpkg removed at build │
│ • = minimal image, no pkg manager │
└─────────────────────────────────────┘
The final image has no package manager — apt/dpkg are removed after installing runtime dependencies, reducing the attack surface.
The entrypoint.sh orchestrates the control-plane processes and manifest deployment:
entrypoint.sh
├── setup_mounts() # mount --make-rshared /, /sys, bpf (Cilium)
├── detect_ip() # detects container IP
├── generate_pki() # generates 3 CAs + 11 certs + SA keys
├── generate_kubeconfigs() # generates 6 kubeconfigs
│
├── containerd ──────────▶ waits for socket
├── etcd ────────────────▶ waits for health (via etcdctl + TLS)
├── kube-apiserver ──────▶ waits for /healthz
├── kube-controller-manager
├── kube-scheduler
├── kubelet
├── kube-proxy
│
└── apply_manifests() [background]
├── taint removal (allows workloads)
├── cilium install (CNI, clean reinstall every boot)
├── waits for Node Ready
├── kubectl apply -f coredns/
├── kubectl apply -k local-path/ (provisioner + default StorageClass)
├── kubectl apply -f haproxy-ingress/
└── kubectl apply -f metrics-server/
All cluster state is stored in bind mounts under ./data/, ensuring persistence across restarts and making the data directly visible/backable on the host:
| Host Path | Container Mount | Contents |
|---|---|---|
./data/etcd/ |
/var/lib/etcd |
etcd data (cluster state) |
./data/containerd/ |
/var/lib/containerd |
Images and containers |
./data/kubelet/ |
/var/lib/kubelet |
Kubelet state and pods |
./data/pki/ |
/etc/kubernetes/pki |
TLS certificates (CAs, certs, keys) |
./data/kubernetes/ |
/etc/kubernetes |
Kubeconfigs (admin, scheduler, etc.) |
./data/local-path/ |
/opt/local-path-provisioner |
Volumes provisioned by the local-path StorageClass |
Additionally, the container bind-mounts host system paths:
| Host Path | Container Path | Mode | Reason |
|---|---|---|---|
/sys |
/sys |
rw |
Cilium BPF, cgroups |
/lib/modules |
/lib/modules |
ro |
Kernel modules (iptables, etc.) |
⚠️ ./data/contains cluster secrets (PKI private keys, kubeconfigs) and, alongside./data/local-path/, all volume data. Both are gitignored — never commit them.
docker compose down -v # removes container + named volumes (bind mounts under ./data/ and ./data/local-path/ are kept)
# To fully wipe cluster data: rm -rf data/* data/local-path/* (irreversible!)All versions are configurable via build args in the Dockerfile:
# Use a specific Kubernetes version
docker compose build --build-arg KUBE_VERSION=v1.35.0
# Use a specific Cilium version
docker compose build --build-arg CILIUM_VERSION=v1.18.0
# Build for arm64 (untested)
docker compose build --build-arg TARGETARCH=arm64| Build Arg | Default | Description |
|---|---|---|
KUBE_VERSION |
v1.36.0 |
Kubernetes version |
ETCD_VERSION |
v3.5.21 |
etcd version |
CONTAINERD_VERSION |
1.7.27 |
containerd version |
RUNC_VERSION |
v1.2.6 |
runc version |
CNI_VERSION |
v1.6.2 |
CNI plugins version |
CILIUM_VERSION |
v1.19.5 |
Cilium version |
CILIUM_CLI_VERSION |
v0.19.4 |
Cilium CLI version |
TARGETARCH |
amd64 |
Target architecture |
| Variable | Default | Description |
|---|---|---|
NODE_NAME |
k8s-one |
Node name in the cluster |
ARGOCD_VERSION |
v3.5.1 |
Argo CD version installed at boot (format: vX.Y.Z) |
Set ARGOCD_VERSION in the root .env file. After changing it, recreate the
container so the selected version is applied during startup:
docker compose up -d --force-recreate| Parameter | Value | Description |
|---|---|---|
CLUSTER_CIDR |
192.168.0.0/16 |
Pod CIDR (Cilium auto-detects from controller-manager) |
SERVICE_CIDR |
10.96.0.0/12 |
ClusterIP CIDR |
CLUSTER_DNS |
10.96.0.10 |
CoreDNS IP |
The kubelet advertises the host's memory as node capacity (the container has no memory limit of its own), so the reserves below are how the cluster tells the scheduler the truth: they deliberately "lie downward" so that pods get a realistic budget while the host stays protected.
| Setting | Value | Effect |
|---|---|---|
systemReserved |
750m / 12Gi |
Reserved for the host's own workload (desktop session, daemons) |
kubeReserved |
250m / 2Gi |
Reserved for control plane + runtime (etcd, apiserver, kubelet, containerd) |
evictionHard |
memory.available: 1Gi |
Kubelet starts evicting pods below this |
enforceNodeAllocatable |
[pods] |
Makes the kubelet write the reserves into the kubepods cgroup (see below for what actually lands there) |
On a 4 CPU / 23.2 GiB host that resolves to:
capacity 4 cpu 24346668Ki (23.2 GiB)
- kubeReserved 250m 2 GiB
- systemReserved 750m 12 GiB
- evictionHard -- 1 GiB
= allocatable 3 cpu ~8.2 GiB <- the scheduling budget
Three consequences worth internalising:
kubectl top nodesreports more than 100% memory./proc/meminfois not namespaced, so the kubelet reads the host's usage while the denominator is the 8.2 GiB allocatable. A figure like207%is not a leak — it is the entire desktop.- Memory has exactly one hard cap, and it is ~9.2 GiB — not the allocatable 8.2 GiB.
enforceNodeAllocatablemakes the kubelet setmemory.maxon thekubepodscgroup to capacity − reserves (9898602496= 9440 MiB), and the scheduler's 8.2 GiB is that figure minus the 1 GiB eviction margin. The cgroup driver iscgroupfs, so the path is/sys/fs/cgroup/kubepods(no.slice). The container itself is unbounded (Memory: 0), and control-plane processes run outsidekubepods, covered only by thesystemReservedreservation — not by an enforced limit. - CPU has no cap at any layer.
cpu.maxis unset (max 100000) on both the container and onkubepods, so pods can burst past the 3 CPU allocatable whenever the host has idle cycles. Only scheduling is bounded by the 3 CPU — consumption is not.
Requests are the scheduling budget. The node's requests are what block rollouts:
when they approach allocatable, a maxSurge replacement pod cannot be placed and
matches 0/1 nodes are available: 1 Insufficient cpu. Actual usage is far below
requests here, so keep an eye on the ratio — and remember that a chart can inject
sidecars of its own (service-mesh proxies, exporters, log shippers) whose requests
land in the pod without ever appearing in the values you wrote.
The config is written by write_kubelet_config() in scripts/entrypoint.sh to
/var/lib/kubelet/config.yaml, which lives on the ./data/kubelet bind mount and
therefore persists across container restarts. The function regenerates the file
at boot whenever its content differs from the heredoc (keeping a timestamped
.bak). A running kubelet does not reload its config, so changes take effect only
after a container restart.
Do not shrink
systemReservedto raise allocatable. It exists precisely because the node shares RAM with the desktop; pods are expected to fit in ~8 GiB.
No credential is versioned. Values live in manifests/**/secrets/ (ignored by
.gitignore) and are applied to the cluster by scripts/create-secrets.sh.
The Argo CD Applications only reference the Secrets via existingSecret —
they never contain a password.
| Secret | Namespace | Source file (gitignored) | Used by |
|---|---|---|---|
authentik-config |
platform |
manifests/argocd/authentik/secrets/authentik.env |
authentik.existingSecret |
authentik-postgresql-auth |
platform + data |
manifests/argocd/authentik/secrets/postgresql-auth.yaml |
PostgreSQL role authentik (init script, data) |
grafana-admin |
monitoring |
manifests/argocd/prometheus-stack/secrets/grafana-admin.yaml |
grafana.admin.existingSecret |
grafana-oidc |
monitoring |
manifests/argocd/prometheus-stack/secrets/grafana-oidc.env |
grafana.envFromSecret (GF_AUTH_GENERIC_OAUTH_* env) |
litellm-env |
platform |
manifests/argocd/litellm/secrets/litellm.env |
litellm.environmentSecrets |
litellm-db |
platform + data |
manifests/argocd/litellm/secrets/litellm-db.yaml |
litellm.db.secret + PostgreSQL init (data) |
litellm-masterkey |
platform |
manifests/argocd/litellm/secrets/litellm-masterkey.yaml |
litellm.masterkeySecretName |
mkcert-ca |
cert-manager |
manifests/built-in/cert-manager/secrets/mkcert-ca.yaml |
root CA for ClusterIssuer local-ca |
postgres-superuser |
data |
manifests/argocd/postgres/secrets/postgres-superuser.yaml |
PostgreSQL superuser (POSTGRES_PASSWORD) |
Formats:
*.env→ created withkubectl create secret generic --from-env-file(e.g.authentik.env).*.yaml→ akind: Secretmanifest (withstringData) applied withkubectl apply -f.
One credential, two namespaces. A Secret is namespaced, and the database lives in
datawhile its consumers live inplatform— socreate-secrets.shapplieslitellm-db.yamlandpostgresql-auth.yamlto both namespaces (apply_yaml_secret_in_ns, which rewrites thenamespace:field of the manifest on the fly). The source file stays the single truth: rotate the password in one place, never in one namespace only.
One credential, two secrets.
authentik-configalso carries the OIDC client credentials that LiteLLM authenticates with (LITELLM_OIDC_CLIENT_ID/LITELLM_OIDC_CLIENT_SECRET) — they must be the same values asGENERIC_CLIENT_ID/GENERIC_CLIENT_SECRETinlitellm-env. Whoever registers the client (the blueprint) and whoever presents it (the proxy) are different apps, so the pair has to exist on both sides: rotate one, rotate both.
scripts/create-secrets.sh # idempotent; uses ./kubeconfig (or $KUBECONFIG)Run it before applying the Argo CD Applications (the existingSecret must
exist). Manually:
kubectl -n platform create secret generic authentik-config \
--from-env-file=manifests/argocd/authentik/secrets/authentik.env \
--dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f manifests/argocd/authentik/secrets/postgresql-auth.yaml
kubectl apply -f manifests/argocd/prometheus-stack/secrets/grafana-admin.yaml# one key of the authentik env
kubectl -n platform get secret authentik-config -o jsonpath='{.data.AUTHENTIK_POSTGRESQL__PASSWORD}' | base64 -d
# Grafana password
kubectl -n monitoring get secret grafana-admin -o jsonpath='{.data.admin-password}' | base64 -d
# every key of a secret
kubectl -n platform get secret authentik-postgresql-auth -o jsonpath='{.data}' | jq- Never commit files under
secrets/nor embed a password in02-application.yaml. - Rotating a password = edit the source file in
secrets/, run the script and restart the workload. For an application role on the shared PostgreSQL the password is written to the database at init: besides the Secret, runALTER USERon the database (the init script only creates a role that does not exist yet). - Make sure nothing is tracked:
git check-ignore manifests/argocd/*/secrets/*.
# Copy kubeconfig from container
docker cp k8s-one:/etc/kubernetes/admin-external.conf ./kubeconfig
# Use it
export KUBECONFIG=./kubeconfig
kubectl get nodes
kubectl get pods -A
kubectl get scThe external kubeconfig uses the container's IP as endpoint. To access from outside the Docker host, replace the IP in the kubeconfig with the host IP:
# Check the current IP in the kubeconfig
grep server kubeconfig
# Replace with the host IP (port 6443 is exposed in docker-compose)
sed -i 's|https://.*:6443|https://<HOST_IP>:6443|' kubeconfigdocker exec k8s-one kubectl --kubeconfig=/etc/kubernetes/admin.conf get pods -Akubectl run nginx --image=nginx:alpine --port=80
kubectl get pods -wapiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-data
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: busybox
command: ["sh", "-c", "echo 'Hello from K8s-One!' > /data/hello.txt && cat /data/hello.txt && sleep 3600"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: my-datakubectl apply -f app.yaml
kubectl logs app
# Hello from K8s-One!local-path provisions node-local hostPath directories, so a PVC is ReadWriteOnce
only. There is no cluster filesystem for ReadWriteMany — use an application-level
mechanism (object storage, NFS, or a database) for shared data.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all
spec:
podSelector: {}
policyTypes: [Ingress, Egress]apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:alpine
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 80
targetPort: 80
type: ClusterIPapiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
annotations:
haproxy.org/ingress.class: haproxy
spec:
rules:
- host: my-app.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80kubectl apply -f ingress.yaml
curl -H "Host: my-app.local" http://localhost:8082/k8s-one/
├── Dockerfile # Multi-stage build (alpine builder + debian runtime)
├── docker-compose.yaml # Execution with persistent volumes
├── README.md # Documentation (English)
├── README.pt-BR.md # Documentation (Portuguese)
│
├── scripts/
│ ├── entrypoint.sh # Orchestration: PKI, configs, processes, manifests
│ ├── deploy-apps.sh # Applies manifests/apps via kustomize (no docker cp)
│ └── create-secrets.sh # Creates/updates Secrets from manifests/**/secrets/
│
├── configs/
│ └── containerd-config.toml # containerd: runc + cgroupfs + overlayfs
│
└── manifests/ # GITIGNORED — mounted ro into the container
├── built-in/ # Core: applied by entrypoint.sh on every boot
│ ├── coredns/ # CoreDNS (one Kubernetes resource per file)
│ │ ├── 01-service-account.yaml
│ │ ├── 02-cluster-role.yaml
│ │ ├── 03-cluster-role-binding.yaml
│ │ ├── 04-configmap.yaml
│ │ ├── 05-deployment.yaml
│ │ └── 06-service.yaml
│ ├── haproxy-ingress/ # HAProxy Ingress (one Kubernetes resource per file)
│ │ ├── 01-namespace.yaml
│ │ ├── 02-service-account.yaml
│ │ ├── 03-cluster-role.yaml
│ │ ├── 04-cluster-role-binding.yaml
│ │ ├── 05-configmap.yaml
│ │ ├── 06-deployment.yaml
│ │ └── 07-service.yaml
│ ├── metrics-server/ # Metrics API (one Kubernetes resource per file)
│ │ ├── 01-service-account.yaml
│ │ ├── 02-aggregated-metrics-reader-cluster-role.yaml
│ │ ├── 03-metrics-server-cluster-role.yaml
│ │ ├── 04-auth-reader-role-binding.yaml
│ │ ├── 05-auth-delegator-cluster-role-binding.yaml
│ │ ├── 06-metrics-server-cluster-role-binding.yaml
│ │ ├── 07-service.yaml
│ │ ├── 08-deployment.yaml
│ │ └── 09-api-service.yaml
│ └── local-path/ # local-path-provisioner + default StorageClass
│ ├── local-path-storage.yaml # Namespace, RBAC, Deployment, StorageClass, ConfigMap
│ └── kustomization.yaml
│ ├── metallb/ # MetalLB L2 (LB for services; see "Local DNS" — not the external path)
│ │ ├── 00-crds.yaml … 07-webhook.yaml
│ │ ├── 02-ipaddresspool.yaml # 192.168.1.200-250 (LAN)
│ │ └── kustomization.yaml
│ └── cert-manager/ # Internal *.lan certificates (mkcert CA)
│ ├── 00-crds.yaml … 05-webhooks.yaml
│ ├── cluster-issuer.yaml # ClusterIssuer "local-ca" (mkcert CA)
│ ├── certificate-dns-lan.yaml# dns.lan + *.lan → secret dns-lan-tls
│ ├── kustomization.yaml
│ └── secrets/
│ └── mkcert-ca.yaml # mkcert root CA (never commit)
├── argocd/ # Applications (Argo CD) — Helm charts
│ ├── authentik/
│ │ ├── 02-application.yaml # existingSecret: authentik-config (db: shared PostgreSQL)
│ │ ├── 03-blueprint.yaml # OIDC provider/group/app (kubectl apply — never through Helm)
│ │ └── secrets/ # GITIGNORED (never commit)
│ │ ├── authentik.env # app env (AUTHENTIK_* + LITELLM_OIDC_*)
│ │ └── postgresql-auth.yaml # PostgreSQL auth (postgres-password/password)
│ ├── litellm/
│ │ ├── 01-repo-secret.yaml # OCI repo (ghcr.io/berriai, enableOCI)
│ │ ├── 02-application.yaml # litellm-helm chart + migrations hook (PreSync)
│ │ └── secrets/ # GITIGNORED (never commit)
│ │ ├── litellm.env # app env (OPENAI_API_KEY, PROXY_BASE_URL, OIDC...)
│ │ ├── litellm-db.yaml # PostgreSQL creds (username/password)
│ │ └── litellm-masterkey.yaml # proxy master key (masterkey)
│ ├── postgres/
│ │ ├── 02-application.yaml # source: this repo (path manifests/postgres)
│ │ └── secrets/ # GITIGNORED (never commit)
│ │ └── postgres-superuser.yaml # superuser password (postgres-password)
│ ├── prometheus-stack/
│ │ ├── 02-application.yaml # existingSecret: grafana-admin + authentik SSO (grafana.ini)
│ │ ├── 03-certificate.yaml # grafana-tls (grafana.lan) — kubectl apply -f
│ │ ├── 04-ingress.yaml # grafana.lan -> chart Service — kubectl apply -f
│ │ └── secrets/
│ │ ├── grafana-admin.yaml # Grafana admin (admin-user/admin-password)
│ │ └── grafana-oidc.env # SSO OIDC client (GF_AUTH_GENERIC_OAUTH_*)
├── postgres/ # PostgreSQL 18 — shared instance (own manifests, from this repo)
│ ├── 01-pvc.yaml # PVC postgres-data (5Gi, local-path)
│ ├── 02-deployment.yaml # postgres:18.6 (ns data)
│ ├── 03-configmap-initdb.yaml # init: creates the app roles/databases
│ ├── 04-service.yaml # Service postgres + NodePort 30432
│ └── kustomization.yaml
├── apps/ # On-demand; one Kubernetes resource per YAML file
│ ├── kustomization.yaml # Composes the app directories
│ └── tileserver/ # TileServer GL
Typical timeline for a first run (cold start, no image cache):
0s ▶ Mount propagation (rshared /, /sys, bpf)
0s ▶ PKI generation (3 CAs, 11 certs, SA keypair)
1s ▶ Kubeconfig generation (6 files)
1s ▶ containerd start → socket ready
2s ▶ etcd start → health check OK
5s ▶ kube-apiserver start → /healthz OK
7s ▶ kube-controller-manager / scheduler / kubelet / kube-proxy
10s ▶ cilium install (clean reinstall every boot)
35s ▶ Node Ready ✓
40s ▶ CoreDNS, local-path-provisioner, HAProxy applied
On subsequent restarts (images already cached), boot drops to ~1-2 min. Provisioned volume data survives via
data/local-path/.
The entrypoint generates the full PKI on first run. Certificates are persisted in the ./data/pki/ bind mount and reused across restarts.
| CA | CN | Usage |
|---|---|---|
ca |
kubernetes-ca |
Cluster root CA |
etcd/ca |
etcd-ca |
etcd CA (separate) |
front-proxy-ca |
front-proxy-ca |
Aggregation layer CA |
| Cert | CA | CN | O (Org) | SANs |
|---|---|---|---|---|
apiserver |
ca |
kube-apiserver |
— | kubernetes, kubernetes.default, *.svc, 127.0.0.1, NODE_IP, 10.96.0.1 |
apiserver-kubelet-client |
ca |
apiserver-kubelet-client |
system:masters |
— |
admin |
ca |
kubernetes-admin |
system:masters |
— |
controller-manager |
ca |
system:kube-controller-manager |
— | — |
scheduler |
ca |
system:kube-scheduler |
— | — |
kubelet |
ca |
system:node:k8s-one |
system:nodes |
— |
kube-proxy |
ca |
system:kube-proxy |
— | — |
front-proxy-client |
front-proxy-ca |
front-proxy-client |
— | — |
etcd/server |
etcd/ca |
etcd-server |
— | localhost, NODE_NAME, 127.0.0.1, NODE_IP |
etcd/client |
etcd/ca |
etcd-client |
— | — |
apiserver-etcd-client |
etcd/ca |
apiserver-etcd-client |
— | — |
| File | Type |
|---|---|
sa.key |
RSA 2048 private key |
sa.pub |
Public key (for token verification) |
All certificates have a validity of 10 years (3650 days).
- Datapath: eBPF
- Pod CIDR:
192.168.0.0/16(auto-detected from kube-controller-manager) - Network Policy: ✅ supported (CiliumNetworkPolicy + k8s NetworkPolicy)
- IPAM: cluster-pool (default)
- kube-proxy replacement: disabled (kube-proxy runs alongside)
- Hubble: ✅ observability & monitoring
Cilium is installed via the Cilium CLI, which manages the Helm chart and provides status monitoring. It is fully uninstalled and reinstalled on every boot (the in-memory BPF datapath does not survive a container restart).
- Mode: iptables
- Service CIDR:
10.96.0.0/12
- ClusterIP:
10.96.0.10 - Forward:
8.8.8.8,1.1.1.1(Google DNS, Cloudflare) - Domain:
cluster.local .lannames: resolved in-cluster by ahostsblock pointing at the MetalLB VIP (192.168.1.200)
The hosts block is needed because the forward above goes straight to public resolvers, which do not know .lan (a private TLD only AdGuard serves) — without it no pod reaches authentik.lan, grafana.lan etc. by name, only by ClusterIP. The IP is the MetalLB VIP (the haproxy-kubernetes-ingress LoadBalancer service), not the 192.168.1.20 used by the AdGuard rewrites: .20 is the host's LAN IP and is unreachable from inside the cluster (it times out). The VIP is reachable, and routes by Host header with the mkcert certs.
It is an explicit list, not a wildcard: the hosts plugin only gained wildcard support on CoreDNS master — no release has it (this cluster runs v1.12.0), so *.lan there would be stored as a literal name and silently never match. The template alternative would work but makes every *.lan answer the VIP, including router.lan (AdGuard points that at 192.168.1.1), with no way to carve an exception (Go/RE2 has no lookahead). A new .lan service is one more word on that line, in manifests/built-in/coredns/04-configmap.yaml. The reload plugin picks the change up in ~30 s, no restart.
AdGuard Home (apps/adguard) is the LAN/tailnet DNS and resolves the internal *.lan names to the cluster. CoreDNS still owns cluster.local (in-cluster DNS) — AdGuard is for accessing apps by name.
Flow of a request to https://dns.lan (AdGuard dashboard):
device → AdGuard (192.168.1.20:53)
→ rewrite "dns.lan → 192.168.1.20" (config in AdGuardHome.yaml, PVC adguard-conf-fs)
→ browser → 192.168.1.20:443 (host-published port)
→ HAProxy Ingress (Host: dns.lan) → service adguard:80 → UI (3000)
- Why
.lan, not.local:.localis reserved for mDNS (RFC 6762). Android and macOS resolve.localvia mDNS and never via unicast DNS — sodns.localfails on those devices even with the right DNS..langoes through the normal unicast resolver. - AdGuard rewrite:
dns.lan → 192.168.1.20(the host's fixed LAN IP — not the MetalLB IP; see below). - External access = host-published ports: the cluster runs inside an isolated docker network (
192.168.32.0/20). MetalLB (inbuilt-in/metallb) announces its LoadBalancer IP (192.168.1.200) inside that docker network, not on the WiFi — so it is not reachable from the LAN. The real path isdocker-composepublishing0.0.0.0:80/443 → NodePort 30080/30443on the host's physical IP (192.168.1.20). The rewrite points to192.168.1.20, not192.168.1.200. - Certificates (cert-manager + mkcert): the
ClusterIssuer local-causes the mkcert root CA (~/.local/share/mkcert/rootCA.pem, imported into Secretcert-manager/mkcert-ca). TheCertificate dns-lanissuesdns.lan+*.laninto Secretinfra/dns-lan-tls, referenced by the Ingress. To avoid browser warnings, install the mkcert CA into each device's trust store (already done on the host viamkcert -install). - Tailscale: global nameserver =
192.168.1.20and the192.168.1.0/24route advertised + approved — tailnet devices resolve*.lanvia AdGuard and reach192.168.1.20. IPv6 (RA) must be off on the router: the IPv6 DNS advertisement (fc00::a/b) is preferred by Android and breaks*.lanresolution.
Works from anywhere with Tailscale on: the tailnet DNS (global nameserver 192.168.1.20) resolves *.lan via AdGuard, and the 192.168.1.0/24 subnet route forwards traffic to 192.168.1.20 (host) → Ingress → app. E.g. https://argocd.lan away from home.
Gotcha (Termux): Termux uses its own
resolv.conf($PREFIX/etc/resolv.conf, pointing at8.8.8.8) — sonslookup/curlinside Termux do not resolve.lan, even though the browser works. Diagnosis:
nslookup argocd.lan 192.168.1.20→ queries AdGuard directly (works).nslookup argocd.lan 100.100.100.100→ tailnet MagicDNS (works if the tailnet DNS is applied on the device). To test access, use the browser (it uses the system DNS).
- Router (LAN): DHCP hands out
192.168.1.20as primary DNS (optional1.1.1.1fallback). Note: with the host down, clients that only have192.168.1.20lose DNS.
Dashboard at https://headlamp.lan (ingress routes by Host; mkcert cert headlamp.lan). Login é SSO no Authentik, feito por um oauth2-proxy na frente do Headlamp.
Arquitetura (por que o proxy): o Headlamp tem dois modos de OIDC, mutuamente exclusivos:
- OIDC nativo: exige login e encaminha o token do usuário para a API. Como o
kube-apiserverdeste cluster não tem flags--oidc-*, a API rejeita o token (401, "the cluster did not accept your sign-in"). - Service account (
HEADLAMP_CONFIG_UNSAFE_USE_SERVICE_ACCOUNT_TOKEN=true): autentica todos pelo SA, mas não exige login — só é seguro atrás de um auth proxy.
Escolhemos o 2º + um proxy: navegador → ingress → oauth2-proxy → Headlamp. O oauth2-proxy (ns ops, 09-oauth2-proxy.yaml) faz o OIDC contra o Authentik (provider "Headlamp", redirect https://headlamp.lan/oauth2/callback) e guarda a sessão num cookie; o ingress aponta para ele, não para o Headlamp. Sem sessão válida, nada chega ao Headlamp.
RBAC: SA headlamp-admin (ns ops) → ClusterRole headlamp-admin — admin amplo, mas sem delete em namespaces, PVs e PVCs (guarda-corpo de dados; ver 03-cluster-role.yaml). O login controla quem entra; a permissão na API é a do SA (admin compartilhado).
Env relevante do Headlamp (05-deployment.yaml): HEADLAMP_CONFIG_UNSAFE_USE_SERVICE_ACCOUNT_TOKEN=true + HEADLAMP_CONFIG_PROXY_AUTH=true (confia nos headers X-Forwarded-* do proxy). O client_id/secret é o mesmo par HEADLAMP_OIDC_* do blueprint, guardado no Secret gitignored oauth2-proxy (create-secrets.sh), junto do cookie_secret.
Alternativa per-user (não usada): configurar OIDC no próprio
kube-apiserver(--oidc-*) + RBAC por identidade — muda o modelo para per-user e exige rebuild/recreate do container.
Logout ("Sair"): o Headlamp não tem logout no servidor (o botão nativo só limpa o token local, e a sessão real é o cookie _oauth2_proxy). Por isso há um plugin próprio (plugin-logout/, montado em /headlamp/static-plugins/logout via ConfigMap) que adiciona um botão Sair no app bar. Ele vai para /oauth2/sign_out?rd=<end-session do Authentik>: limpa o cookie do proxy e encerra a sessão SSO no Authentik, voltando para o login. O plugin é escrito à mão em UMD (o Headlamp injeta os módulos em window.pluginLib), sem toolchain/npm.
Fallback por token (quando o prompt pede token):
kubectl -n ops get secret headlamp-admin-token -o jsonpath='{.data.token}' | base64 -dThe headlamp-admin-token secret (type kubernetes.io/service-account-token) is long-lived (K8s 1.24+). Since Headlamp runs with --in-cluster, it may authenticate automatically via the projected token — the command above is for when the login prompt asks for a token.
Accessible at https://argocd.lan (Ingress ns argocd → argocd-server:80, mkcert TLS argocd.lan). Requires --insecure on argocd-server: without it, the ingress (which terminates TLS and forwards plain HTTP to the backend) causes a 307 redirect loop. The patch is re-applied by entrypoint.sh after applying the upstream install.yaml (persistent across reboots).
Login: user admin, password:
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -dTip: login uses user
admin+ the password from the secret above (it matches the hash inargocd-secret/admin.password). If the browser rejects it, check autofill/cache (type the password manually; hard refresh or incognito) — it is not the headlamp/dns password.
Ingress/cert manifests: manifests/apps/argocd/ (gitignored).
Identity provider at https://authentik.lan (chart authentik 2026.8.1, ns platform, mkcert cert authentik-tls). Its database is the shared PostgreSQL 18 of the data namespace (see PostgreSQL (data) below) — database authentik, owned by the role authentik — so the chart's embedded postgresql: is enabled: false and the connection comes entirely from the authentik-config Secret (AUTHENTIK_POSTGRESQL__*, delivered to server and worker by envFrom). It is here to be the single login for the cluster's apps; wired to it so far: LiteLLM, Headlamp and Grafana (Argo CD planned).
Groups, providers and applications are declarative, via a blueprint the chart mounts into the worker:
kubectl apply -f manifests/argocd/authentik/03-blueprint.yaml # the blueprint ConfigMap
kubectl apply -f manifests/argocd/authentik/02-application.yaml # then let Argo CD sync the chartmanifests/argocd/authentik/03-blueprint.yamlis a ConfigMap holding three blueprints, one per app:litellm-oidc.yaml(grouplitellm-users, OAuth2 providerLiteLLM, application and bindings),headlamp-oidc.yaml(same, for Headlamp — with oauth2-proxy running the flow) andgrafana-oidc.yaml(same, for Grafana, which speaks OIDC natively).- It is applied with
kubectl, never through Helm: blueprint tags (!Find,!KeyOf,!Env) are custom YAML, and Helm'svalues → toYamlround-trip destroys them (they arrive in the cluster as bare strings and the blueprint fails). - The chart mounts every name in
blueprints.configMaps(in02-application.yaml) into the worker at/blueprints/mounted/cm-<name>; the worker discovers all*.yamlthere. - Discovery is event-driven, not boot-time. What triggers it is the file watcher (
on_created/on_modified) plus an hourly scheduled run. A ConfigMap that is already populated when the worker starts fires nothing — the mount happens before the process is up. To force it without waiting: change the ConfigMap data (e.g. a comment in the blueprint) and the kubelet resyncs the volume, producing the events.kubectl annotatedoes not work: metadata does not make the kubelet resync. - Idempotency comes from
identifiers, not fromid: the importer builds afilter()fromidentifiersand, if it finds the object, updates it (partial=True); otherwise it creates. The entryidexists only so other entries can point at it with!KeyOf. An entry withoutidentifiersaborts with "No or invalid identifiers". - List-valued fields have an empty default and must be declared.
grant_typesisArrayField(..., default=list)on the model: the UI wizard fills it in, a blueprint does not. Omit it and the provider is created withgrant_types = {}— it then rejects every grant (Invalid grant_type for providerin the server log) and/authorizeanswersinvalid_request, which looks like a completely unrelated bug. Same trap for any otherArrayField(property_mappingsabove is the same shape, with a less obvious symptom: a token without theemailclaim). - The client credentials are
!Env, resolved against the worker's environment — which receives every key of theauthentik-configSecret (envFrom). They live insecrets/authentik.env(gitignored). Note!EnvreturnsNonefor a missing variable instead of failing loudly, so the salt is on the other side: the authentik serializer rejects a nullclient_secret, and the sync errors out. - The admin's email is kept in sync with the Secret.
AUTHENTIK_BOOTSTRAP_EMAILis only consumed when the database is created — fix the email afterwards and the user keeps the old one. That matters beyond tidiness: the email is what LiteLLM uses to provision its own user on first login. So the blueprint carries anauthentik_core.userentry settingemail: !Env AUTHENTIK_BOOTSTRAP_EMAIL(the value stays in the gitignored Secret rather than being written into a tracked file). The importer'spartial=Truemeans only that field is touched on the admin user.
Admin login is akadmin:
kubectl -n platform get secret authentik-config -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_EMAIL}' | base64 -d; echo
kubectl -n platform get secret authentik-config -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_PASSWORD}' | base64 -d; echoThe password above is the bootstrap value: it is consumed when the database is created. Editing it in
secrets/authentik.envafterwards does not change the password of an existingakadmin— that is done in the UI (Settings → Password) or by resetting the flow.
OpenAI-compatible proxy at https://litellm.lan (Ingress ns platform → litellm:4000, dedicated mkcert cert litellm-tls). It replaces the LiteLLM that used to run in the infra/ docker-compose stack; the database is the cluster's shared PostgreSQL 18 (ns data, database litellm, ?schema=litellm) — the same instance that serves the Authentik, in a database of its own.
DNS: litellm.lan must be added as a rewrite in AdGuard (Filters → DNS rewrites → 192.168.1.20), like the other .lan names. AdGuard rewrites are per host, not wildcards, and the config lives inside the adguard-conf-fs PVC (not in this repo), so this is a manual one-time step.
# master key — the API bearer token (NOT a UI login, see below)
kubectl -n platform get secret litellm-masterkey -o jsonpath='{.data.masterkey}' | base64 -d
# list models
curl -sk https://litellm.lan/v1/models -H "Authorization: Bearer $MASTER_KEY"UI login is SSO through Authentik (provider LiteLLM, see the Authentik section above). Redirect URI: https://litellm.lan/sso/callback; access is restricted to the litellm-users group. The whole switch is environment:
- The keys are
GENERIC_*, notGOOGLE_*. LiteLLM picks the provider in a Google → Microsoft → Genericif/elif, so whileGOOGLE_CLIENT_IDexists the generic block is dead code — removing the two Google keys is what actually flips the provider. - Three endpoints are configured by hand (
authorize,token,userinfo): LiteLLM does not use OIDC discovery, so there is no/.well-knownlookup. PROXY_BASE_URL(https://litellm.lan) is what composes the redirect URI; it must match what is registered on the provider.- The master key is unaffected by SSO — it stays the API bearer token. It is not a UI password:
POST /loginwithadmin+ master key returns 401 here. - Without a
LITELLM_LICENSE, SSO is capped at 5 users (ui_sso.pyrefuses beyond that). TheLiteLLM_UserTablestarts empty, so it only matters if more people ever log in.
TLS when calling Authentik. The pod reaches Authentik at https://authentik.lan, which inside the cluster resolves to the MetalLB VIP and serves a mkcert certificate — not trusted by the Debian CA bundle in the image, so the token exchange would fail hostname/issuer verification. The deployment therefore mounts the mkcert root (the ca.crt of the litellm-tls secret, already in the platform namespace) and an initContainer concatenates it with the system bundle, pointing SSL_CERT_FILE/REQUESTS_CA_BUNDLE at the result. Concatenating rather than replacing matters: the proxy also calls api.openai.com, whose cert is not mkcert-signed.
Deployment details worth knowing before touching it:
- Chart: official
litellm-helm, pulled as an OCI chart fromghcr.io/berriai(the classic Helm indexberriai.github.io/litellm-helmis 404). The repo Secret'surlis the parent of the chart in the OCI path — Argo CD buildsoci://<url>/<chart>, sourl: ghcr.io/berriai+chart: litellm-helm.targetRevisionmust be an exact tag (OCI has no semver ranges). - No IngressClass in this cluster: every Ingress routes via the
haproxy.org/ingress.classannotation and has CLASS<none>. The chart'singress.classNameis therefore set to""(its default,nginx, would renderingressClassName: nginxand break routing). - One PreSync hook runs before every sync: the chart's own
litellm-migrationsjob, which runsprisma migrate deploybefore the Deployment. It is safe to re-run. Nothing creates the role/database/schema at sync time — they are born with the instance, from the init script of the shared PostgreSQL (manifests/postgres/03-configmap-initdb.yaml). ENFORCE_PRISMA_MIGRATION_CHECK=trueis required too. Without it LiteLLM logs "migration failed but continuing startup" and exits 0 — the Job shows asCompletedagainst a half-migrated database. With it, a migration failure fails the hook and stops the sync.- Memory: 2Gi limit, not less. Both the migration job (~1.7Gi peak) and the proxy are OOMKilled at 1Gi.
strategy: Recreateavoids two proxies during a rollout on this single, memory-tight node. - Metrics need the callback:
/metricsonly exists whenlitellm_settings.callbacks: [prometheus]is set — without it LiteLLM returns 404 and the Prometheus target stays DOWN (the ServiceMonitor itself works: the scrape does happen). With the callback on, the endpoint also demands the API key, hencerequire_auth_for_metrics_endpoint: false(it is a ClusterIP endpoint).
Manifests: manifests/argocd/litellm/. Secrets: see the table above.
Cluster metrics and dashboards: kube-prometheus-stack (chart 90.0.0, ns monitoring), with Grafana 13.2.1-distroless, a 10Gi local-path PVC and the datasource/dashboard sidecars reading ConfigMaps. Served at https://grafana.lan — Ingress grafana + Certificate grafana-tls (manifests/argocd/prometheus-stack/03-certificate.yaml and 04-ingress.yaml, applied with kubectl apply -f, because the Application points at the third-party chart and not at this repo).
SSO through Authentik (provider Grafana, blueprint grafana-oidc.yaml). Grafana speaks OIDC natively — no proxy in front like Headlamp; Authentik only delivers the claims:
- Access through the
grafana-usersgroup (Application binding, a single place: Grafana does not useallowed_groups). - Role from the group, via
role_attribute_path(JMESPath):grafana-admins→GrafanaAdmin, everyone else →Viewer. Unlike LiteLLM there is no custom scope mapping here: Grafana evaluates thegroupsclaim, and that claim already comes from theprofilescope. What Grafana compares is the group name, so the group→role mapping lives ingrafana.ini, not in the blueprint. role_attribute_strict = true: if thegroupsclaim is missing the login is denied instead of silently demoting everyone toViewer(the LiteLLM lesson, which fell back tointernal_user_viewerwithout telling anyone).allow_assign_grafana_admin = trueis what makes theGrafanaAdminabove count as a server admin; without it it would only be org Admin.- The role is re-synced on every login: promoting/demoting someone means editing the group in Authentik, not Grafana.
- The local login (
admin+ thegrafana-adminsecret) still works — it is the break-glass path (it does not go through Authentik) and it is what the dashboard sidecars use to talk to the local API.
Three details that are expensive to get wrong:
root_urlis mandatory. The flow'sredirect_uriis derived from it; withoutroot_urlGrafana builds the URL from the requestHost(which arrives ashttp, behind the ingress) and Authentik rejects it for not matching thehttps://grafana.lan/login/generic_oauthregistered on the provider.- The mkcert CA is mounted, through
extraSecretMounts. The pod is distroless (no shell) and runs with a read-only rootfs, so there is no way to concatenate a bundle the way LiteLLM does: theca.crtfrom thegrafana-tlssecret itself is mounted (every cert-manager tls secret carries the issuer'sca.crt) andtls_client_capoints at it — the Go equivalent of oauth2-proxy's--provider-ca-file. Do not useextraVolumesfor this: the chart template only rendersexistingClaim/hostPath/csi/configMap/emptyDir, and asecret:volume falls silently into an emptyemptyDir— the pod comes up, the file does not exist, and SSO only fails at login time, far from the cause. - The credential never enters
grafana.ini. The client_id/secret pair comes from thegrafana-oidcSecret asGF_AUTH_GENERIC_OAUTH_*env vars (envFromSecret), which override the ini — that is what the chart'sassertNoLeakedSecretschecks at render time.
Manifests: manifests/argocd/prometheus-stack/. Secrets: see the table above.
The cluster's shared database instance: one PostgreSQL 18.6 — the official postgres:18.6 image, no chart — in namespace data, serving both apps that need a real SQL database, each in its own database and role: litellm (role litellm, schema litellm, Prisma's) and authentik (role authentik, Django's). One instance, two tenants: the roles are created with least privilege (NOSUPERUSER NOCREATEDB NOCREATEROLE) and the superuser never leaves the pod.
Its manifests are versioned in this repo, under manifests/postgres/, because there is no upstream chart to pin — only the official image. The Application postgres (manifests/argocd/postgres/02-application.yaml) therefore follows the vaultwarden pattern: source pointing at this repository, path: manifests/postgres, where a kustomization.yaml composes the PVC, the Deployment, the init ConfigMap and the Service (Argo CD detects kustomize on its own). The PVC carries Prune=false,Delete=false — it holds real data and must not vanish when the Application is pruned or deleted.
In-cluster the address is postgres.data.svc.cluster.local:5432, which is how LiteLLM and Authentik connect. From the host, the Service is a NodePort (30432) that docker-compose publishes as 127.0.0.1:5432:30432 — loopback on purpose: every other published port exists so the LAN can reach the host, but the database must not leave it. NodePort and not LoadBalancer for the same reason the ingresses are: the MetalLB VIP is announced inside the cluster's docker network and is unreachable from the LAN. And 30432 and not 5432 because a NodePort has to sit in the apiserver's 30000-32767 range.
Authentication is scram-sha-256 for every remote connection: the image's entrypoint appends host all all all scram-sha-256 to pg_hba.conf, and that line catches all TCP — including the traffic that arrives through the NodePort. The only trust left is the unix socket and the loopback inside the pod (initdb's default), unreachable from outside, since NodePort traffic arrives with the client's source IP, never 127.0.0.1. POSTGRES_HOST_AUTH_METHOD is deliberately left unset — setting it to trust would be a passwordless superuser. The superuser password lives in the gitignored Secret postgres-superuser; each application only ever receives the credentials of its own role, in Secrets applied to both platform and data (see Secrets).
The volume mounts at
/var/lib/postgresql, not at/var/lib/postgresql/data. In PostgreSQL 18 the image movedPGDATAto/var/lib/postgresql/18/dockerwith theVOLUMEdeclared on the parent, and mounting at the v15–v17 path makes the entrypoint abort at boot with "there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume)". LeavingPGDATAat its default is also what keepspg_upgrade --linkviable for a future major upgrade.
Storage is provided by local-path-provisioner (Rancher v0.0.30) in the
local-path-storage namespace. It dynamically provisions volumes as hostPath
directories under /opt/local-path-provisioner, persisted on the host by the
./data/local-path bind mount.
- Provisioner:
rancher.io/local-path - Data path:
/opt/local-path-provisioner(bind mount from./data/local-path)
| StorageClass | Provisioner | Access | Binding | Reclaim | Expansion |
|---|---|---|---|---|---|
local-path (default) |
rancher.io/local-path |
RWO | WaitForFirstConsumer |
Delete |
not supported |
kubectl get sc
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
# local-path (default) rancher.io/local-path Delete WaitForFirstConsumerVolumes are not redundant: they live as plain directories on the host disk
under data/local-path/. Back that directory up if the data matters. Since
local-path has no online expansion, PVC sizes are fixed (allowVolumeExpansion
is unset).
History. Until 26/09/2026 storage was Rook-Ceph (RBD + CephFS). It was replaced by local-path-provisioner: the single-node Ceph consumed ~2 GiB of requests and ~700m of CPU and was prone to nbd/OSD deadlocks.
- Symptom: editing a PVC's
resources.requests.storageis rejected. - Cause: the
local-pathStorageClass does not setallowVolumeExpansion. - Workaround: recreate the PVC and restore the data (or migrate to a new larger PVC). Sizes are fixed at creation.
Edit manifests/built-in/coredns/04-configmap.yaml, forward section:
forward . 8.8.8.8 1.1.1.1 {
Edit configs/containerd-config.toml:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = false # set to true if host uses systemd cgroupsUpdate in two places:
scripts/entrypoint.sh→CLUSTER_CIDR- Cilium install command (entrypoint.sh →
cilium install --set ipam.operator.clusterPoolIPv4PodCIDRList=...) Rebuild required.
docker compose logs --tail 50Common causes:
- Missing
--privilegedin docker-compose /sysnot mounted as shared
kubectl describe pod <pod-name> -n <namespace>Common causes:
- Cilium hasn't installed the CNI yet → wait for cilium-agent to be Running
- Mount propagation error → check that
/sysis mounted rw
local-path uses volumeBindingMode: WaitForFirstConsumer, so a PVC stays
Pending until a pod that consumes it is scheduled. That is expected, not a failure.
kubectl describe pvc <pvc-name> -n <namespace>
kubectl -n local-path-storage logs deploy/local-path-provisionerCommon causes:
- No pod consuming the PVC yet →
WaitForFirstConsumeris doing its job - Provisioner not Running → check the logs above
- Volume data missing after a container recreate → confirm the
./data/local-pathbind mount exists
kubectl logs -n kube-system -l k8s-app=kube-dnsCommon causes:
- Loop detection → already fixed with forward to 8.8.8.8
- Corefile syntax error → check
manifests/built-in/coredns/04-configmap.yaml
kubectl describe node k8s-oneCommon causes:
- CNI not installed → Cilium still initializing
- kubelet can't communicate with apiserver → check certs
# All logs mixed
docker compose logs -f
# Filter by component
docker compose logs -f | grep apiserver
docker compose logs -f | grep kubelet
docker compose logs -f | grep etcddocker compose down -v # removes container + all named volumes (keeps ./data/local-path/)
docker compose up -d # fresh start
# To also wipe volume data: rm -rf data/local-path/* (irreversible!)| Requirement | Minimum | Recommended |
|---|---|---|
| Docker | 24.0+ | 27.0+ |
| Docker Compose | v2.20+ | v2.30+ |
| RAM | 16 GB | 24 GB |
| CPU | 2 cores | 4 cores |
| Disk | 10 GB (image + volume data) | 20 GB+ |
| OS | Linux (kernel 5.10+) | Linux (kernel 6.x) |
| Arch | amd64 | amd64 |
The RAM figures follow from the kubelet reserves, not from the cluster's own footprint:
systemReserved(12Gi) +kubeReserved(2Gi) +evictionHard(1Gi) are subtracted from host capacity before pods get anything. A 16 GB host leaves ~1 GiB for pods; 24 GB leaves ~9 GiB. See Resource Reserves for the full math.
| Port | Protocol | Usage |
|---|---|---|
6443 |
TCP | Kubernetes API Server |
8082 |
TCP | HAProxy Ingress HTTP (→ NodePort 30080) |
8443 |
TCP | HAProxy Ingress HTTPS (→ NodePort 30443) |
5432 |
TCP | PostgreSQL (→ NodePort 30432, host loopback only) |
- Not HA: single node, no redundancy. etcd, apiserver, etc. are single-instance.
- Not for production: intended for development, testing, CI/CD, lab environments.
- Storage without redundancy:
local-pathvolumes are plain hostPath directories on the host disk. - Privileged mode: the container runs with
--privileged(required for kubelet/containerd). - amd64 only: arm64 may work with
--build-arg TARGETARCH=arm64but is untested. - No systemd: uses
cgroupfsas cgroup driver (no systemd inside the container). - Cert rotation: disabled. Certificates last 10 years. For long-lived clusters, consider implementing rotation.
MIT