Key Takeaways
- Default k3s is not production-secure: k3s ships with minimal configuration for simplicity. Hardening requires: RBAC, NetworkPolicies, Pod Security Standards, secret encryption, and audit logging — none of which are configured by default.
- NetworkPolicies are useless without a CNI that enforces them: k3s’s default Flannel CNI does not enforce NetworkPolicies. Install Flannel with
--flannel-backend=vxlan --flannel-backend=wireguard-nativeplus a network policy controller, or switch to Calico or Cilium. - Disable the default service account token:
automountServiceAccountToken: falsein every Pod/Deployment spec. The default service account token is mounted automatically and gives any compromised container API access. - Run kube-bench:
kube-bench(open-source CIS Kubernetes Benchmark auditor) identifies specific misconfigurations with remediation commands. Run it after initial install and after every major cluster change.
Introduction
Direct Answer: How do I harden a k3s Kubernetes cluster on Ubuntu 24.04 in 2026?
Hardening a k3s cluster on Ubuntu 24.04 requires six steps: (1) Start k3s with security flags — --kube-apiserver-arg=anonymous-auth=false, --kube-apiserver-arg=audit-log-path=/var/log/k3s-audit.log, and --secrets-encryption; (2) Install a CNI that enforces NetworkPolicies (Calico: kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml); (3) Create default-deny NetworkPolicies for every namespace; (4) Configure Pod Security Standards by labelling namespaces with pod-security.kubernetes.io/enforce: restricted; (5) Create least-privilege RBAC service accounts for each application instead of using the default service account; (6) Run kube-bench and resolve all FAIL findings. These steps bring a default k3s cluster from a default-insecure state to CIS Kubernetes Benchmark Level 1 compliance.
Prerequisites
This guide extends the k3s Kubernetes Install on Ubuntu 24.04 guide. A running k3s cluster is required.
# Verify k3s is running
sudo kubectl get nodes
sudo k3s --version
Expected output:
NAME STATUS ROLES AGE VERSION
k3s-server Ready control-plane,master 10m v1.32.2+k3s1
k3s version v1.32.2+k3s1 (abc12345)
Part 1: Secure k3s Startup Flags
Reinstall k3s with security-hardening flags. These flags modify the API server and kubelet behaviour:
# Uninstall existing k3s (data is preserved in /var/lib/rancher/k3s/)
/usr/local/bin/k3s-uninstall.sh 2>/dev/null || true
# Create audit policy file
sudo mkdir -p /etc/k3s
sudo tee /etc/k3s/audit-policy.yaml << 'EOF'
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Log all requests at the Metadata level (minimal, captures what happened)
- level: Metadata
omitStages: [RequestReceived]
# Log full request/response for secrets and configmaps (sensitive resources)
- level: RequestResponse
resources:
- group: ""
resources: ["secrets", "configmaps"]
EOF
# Install k3s with security flags
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \
--secrets-encryption \
--kube-apiserver-arg=anonymous-auth=false \
--kube-apiserver-arg=audit-log-path=/var/log/k3s/audit.log \
--kube-apiserver-arg=audit-log-maxage=30 \
--kube-apiserver-arg=audit-log-maxbackup=10 \
--kube-apiserver-arg=audit-log-maxsize=100 \
--kube-apiserver-arg=audit-policy-file=/etc/k3s/audit-policy.yaml \
--kube-apiserver-arg=enable-admission-plugins=NodeRestriction \
--kubelet-arg=anonymous-auth=false \
--kubelet-arg=read-only-port=0 \
--disable=traefik" sh -
sudo mkdir -p /var/log/k3s
sudo systemctl status k3s --no-pager | grep "Active:"
Expected output:
Active: active (running) since Fri 2026-04-25 13:00:00 UTC; 15s ago
Flag explanations:
--secrets-encryption: Encrypts Kubernetes secrets at rest in etcd using AES-CBC--kube-apiserver-arg=anonymous-auth=false: Disables anonymous access to the API server--kube-apiserver-arg=audit-log-path: Enables audit logging to a file--kubelet-arg=read-only-port=0: Disables the unauthenticated kubelet read-only port (10255)--disable=traefik: Disable default ingress (add your own, hardened version)
# Verify secret encryption is active
sudo cat /var/lib/rancher/k3s/server/cred/encryption-config.json | \
python3 -c "import json,sys; d=json.load(sys.stdin); \
print('Encryption provider:', d['resources'][0]['providers'][0].get('aescbc', {}).get('keys', [{'name':'none'}])[0]['name'])"
Expected output:
Encryption provider: aescbc-key-1
Part 2: Network Policies — Default Deny
By default, all pods in a k3s cluster can communicate with all other pods. Network policies restrict this:
# Install Calico CNI for NetworkPolicy enforcement
# (Flannel's default does not enforce NetworkPolicies)
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml
# Wait for Calico to be ready
kubectl wait --for=condition=ready pod -l k8s-app=calico-node -n kube-system --timeout=120s
echo "Calico ready"
Expected output:
pod/calico-node-xxxxx condition met
Calico ready
# Create a default-deny-all NetworkPolicy for your application namespace
kubectl create namespace myapp 2>/dev/null || true
kubectl apply -f - << 'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: myapp
spec:
podSelector: {} # Applies to ALL pods in this namespace
policyTypes:
- Ingress
- Egress
# No ingress or egress rules = deny everything
EOF
# Verify the policy exists
kubectl get networkpolicy -n myapp
Expected output:
NAME POD-SELECTOR AGE
default-deny-all <none> 5s
# Now add explicit allow rules as needed
kubectl apply -f - << 'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-db
namespace: myapp
spec:
podSelector:
matchLabels:
role: database # This policy applies to pods labelled role=database
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
role: api # Only pods labelled role=api can connect to the database
ports:
- port: 5432
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-egress-dns
namespace: myapp
spec:
podSelector:
matchLabels:
role: api
policyTypes:
- Egress
egress:
- ports:
- port: 53
protocol: UDP # Allow DNS resolution
- to:
- podSelector:
matchLabels:
role: database # Allow outbound to database pods
ports:
- port: 5432
EOF
Part 3: Pod Security Standards
Pod Security Standards (PSS) replaced PodSecurityPolicy in Kubernetes 1.25+. Labels on namespaces enforce security profiles:
# Label the production namespace with "restricted" security level
kubectl label namespace myapp \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.32 \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/warn=restricted
kubectl get namespace myapp --show-labels
Expected output:
NAME STATUS AGE LABELS
myapp Active 5m pod-security.kubernetes.io/enforce=restricted,...
The restricted profile requires pods to:
- Run as non-root user
- Drop all capabilities (
securityContext.capabilities.drop: ["ALL"]) - Use read-only root filesystem
- Not use privileged mode
- Not use host namespaces (hostNetwork, hostPID, hostIPC)
A compliant Pod spec:
# compliant-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: secure-app
namespace: myapp
spec:
automountServiceAccountToken: false # ← Never mount default SA token
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: python:3.12-slim
command: ["python", "-m", "http.server", "8080"]
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
limits:
memory: "256Mi"
cpu: "500m"
requests:
memory: "64Mi"
cpu: "100m"
volumeMounts:
- mountPath: /tmp
name: tmp-volume
volumes:
- name: tmp-volume
emptyDir: {}
kubectl apply -f compliant-pod.yaml
kubectl get pod secure-app -n myapp
Expected output:
NAME READY STATUS RESTARTS AGE
secure-app 1/1 Running 0 10s
Part 4: RBAC — Least Privilege
Never use the default service account. Create dedicated service accounts with minimal permissions:
# Create a service account for the API application
kubectl apply -f - << 'EOF'
apiVersion: v1
kind: ServiceAccount
metadata:
name: api-service-account
namespace: myapp
automountServiceAccountToken: false # Don't auto-mount the token
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: api-role
namespace: myapp
rules:
# Only the specific permissions this service needs
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list"] # Read-only access to ConfigMaps
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"] # Read specific secrets only
resourceNames: ["app-secrets"] # Limited to specific named secret
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: api-role-binding
namespace: myapp
subjects:
- kind: ServiceAccount
name: api-service-account
namespace: myapp
roleRef:
kind: Role
apiGroup: rbac.authorization.k8s.io
name: api-role
EOF
# Verify RBAC is applied
kubectl auth can-i get secrets --as=system:serviceaccount:myapp:api-service-account -n myapp
kubectl auth can-i delete pods --as=system:serviceaccount:myapp:api-service-account -n myapp
Expected output:
yes
no
The service account can get secrets but cannot delete pods — least-privilege principle confirmed.
Part 5: Secrets Management
k3s with --secrets-encryption encrypts secrets at rest. For additional security:
# Create a Kubernetes secret (encrypted at rest by k3s)
kubectl create secret generic app-secrets \
--from-literal=DB_PASSWORD="strong_password_here" \
--from-literal=JWT_SECRET="64_char_random_string_here" \
-n myapp
# Verify the secret exists
kubectl get secret app-secrets -n myapp
# Verify it's encrypted in etcd (raw value should not be visible)
sudo sqlite3 /var/lib/rancher/k3s/server/db/state.db \
"SELECT value FROM kine WHERE name LIKE '%app-secrets%' LIMIT 1" 2>/dev/null | \
head -c 50 | od -c | head -2
Expected output (encrypted — not readable):
k8s:enc:aescbc:v1:key1:...binary gibberish...
The secret is encrypted at rest. Accessing it requires the decryption key stored in k3s’s encryption config.
Part 6: Run kube-bench (CIS Benchmark Audit)
# Run CIS Kubernetes Benchmark against the control plane
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
# Wait for completion and get results
sleep 30
kubectl logs -l app=kube-bench -n default | grep -E "PASS|FAIL|WARN" | head -30
Expected output (after hardening):
[PASS] 1.1.1 Ensure that the API server pod specification file permissions are set to 600
[PASS] 1.2.1 Ensure that the --anonymous-auth argument is set to false
[PASS] 1.2.6 Ensure that the --audit-log-path argument is set
[PASS] 3.2.1 Ensure that a minimal audit policy is created
[WARN] 4.2.6 Ensure that the --protect-kernel-defaults argument is set to true
[FAIL] 4.2.12 Ensure that the RotateKubeletServerCertificate argument is set to true
# Fix FAIL: Enable kubelet server certificate rotation
# Add to k3s server flags and restart:
sudo sed -i 's/ExecStart=\/usr\/local\/bin\/k3s/ExecStart=\/usr\/local\/bin\/k3s --kubelet-arg=rotate-server-certificates=true/' /etc/systemd/system/k3s.service
sudo systemctl daemon-reload && sudo systemctl restart k3s
# Rerun kube-bench after fixing findings
Target: zero FAIL results for the Level 1 profile (the CIS Benchmark’s baseline for production clusters).
Security Checklist Summary
echo "=== K3S SECURITY AUDIT ==="
echo ""
echo "[ Anonymous auth disabled ]"
kubectl get --raw /healthz 2>&1 | grep -q "Unauthorized" && \
echo " ✓ Anonymous access denied" || \
echo " ✗ Anonymous access allowed — check --anonymous-auth=false flag"
echo ""
echo "[ Secret encryption active ]"
sudo test -f /var/lib/rancher/k3s/server/cred/encryption-config.json && \
echo " ✓ Encryption config exists" || \
echo " ✗ Encryption not configured — reinstall with --secrets-encryption"
echo ""
echo "[ Audit logging active ]"
sudo test -f /var/log/k3s/audit.log && \
echo " ✓ Audit log exists: $(sudo wc -l < /var/log/k3s/audit.log) events" || \
echo " ✗ Audit log missing — check audit-log-path flag"
echo ""
echo "[ NetworkPolicies in myapp namespace ]"
kubectl get networkpolicy -n myapp --no-headers 2>/dev/null | wc -l | \
xargs -I{} bash -c '[ {} -gt 0 ] && echo " ✓ {} NetworkPolicy/ies configured" || echo " ✗ No NetworkPolicies — all traffic allowed"'
echo ""
echo "[ Pod Security Standard on myapp ]"
kubectl get namespace myapp -o jsonpath='{.metadata.labels}' 2>/dev/null | \
grep -q "restricted" && \
echo " ✓ Restricted PSS enforced on myapp" || \
echo " ✗ No Pod Security Standard set"
echo ""
echo "[ kubelet read-only port ]"
curl -s --max-time 2 http://localhost:10255/healthz 2>/dev/null && \
echo " ✗ kubelet read-only port 10255 is OPEN — add --kubelet-arg=read-only-port=0" || \
echo " ✓ kubelet read-only port is closed"
Expected output (hardened cluster):
=== K3S SECURITY AUDIT ===
[ Anonymous auth disabled ]
✓ Anonymous access denied
[ Secret encryption active ]
✓ Encryption config exists
[ Audit logging active ]
✓ Audit log exists: 847 events
[ NetworkPolicies in myapp namespace ]
✓ 3 NetworkPolicy/ies configured
[ Pod Security Standard on myapp ]
✓ Restricted PSS enforced on myapp
[ kubelet read-only port ]
✓ kubelet read-only port is closed
Troubleshooting
Pods failing to start after enabling Pod Security Standards
Cause: Pod spec doesn’t meet the restricted profile requirements.
Fix:
kubectl get events -n myapp | grep "violates PodSecurity"
# Add the missing security context fields to your Deployment spec
NetworkPolicy blocking legitimate traffic
Cause: Default-deny policy is too restrictive — missing allow rule. Fix:
# Check if Calico is enforcing policies
kubectl get pods -n kube-system | grep calico
# Add an explicit allow rule for the blocked traffic path
kube-bench can’t connect to API
Cause: kube-bench job needs access to the kubelet config file.
Fix: Follow the k3s-specific kube-bench instructions at github.com/aquasecurity/kube-bench/blob/main/docs/running.md#running-in-a-k3s-cluster.
Conclusion
A hardened k3s cluster in 2026 has: API server anonymous auth disabled, secrets encrypted at rest, audit logging capturing security-relevant events, Calico NetworkPolicies enforcing default-deny traffic isolation, Pod Security Standards preventing privileged container launches, and RBAC least-privilege service accounts. The kube-bench CIS audit confirms zero FAIL findings at Level 1.
This hardening guide builds on k3s Kubernetes Install on Ubuntu 24.04 and pairs with Docker Security Best Practices 2026 for the container layer underneath Kubernetes.
People Also Ask
Is k3s secure enough for production use?
k3s is production-ready after hardening. By default, k3s prioritises simplicity over security (no RBAC enforcement, no NetworkPolicies, no audit logging) — but these are all configurable with the flags described in this guide. Many companies run k3s in production for edge, IoT, and small-to-medium cluster workloads. The CIS Kubernetes Benchmark Level 1 compliance achieved by following this guide is the industry standard for production Kubernetes security.
What is the difference between k3s and a full Kubernetes distribution for security?
Full Kubernetes distributions (kubeadm, Rancher RKE2) provide more security features out of the box, but the security posture achievable with k3s + hardening is equivalent. RKE2 in particular ships with hardening enabled by default (it is designed as a CIS-compliant distribution). k3s requires more manual hardening steps but is operationally simpler for small clusters. If security-first configuration is a requirement and you prefer less manual work, consider RKE2 as an alternative to k3s.
Further Reading
- k3s Kubernetes Install on Ubuntu 24.04 — prerequisite: install k3s before hardening
- Docker Security Best Practices 2026 — secure the containers that k3s runs
- Ubuntu 24.04 LTS Server Setup Checklist — harden the OS before hardening Kubernetes
- SSH Hardening Guide 2026 — secure the control plane access path
Tested on: Ubuntu 24.04 LTS (3× Hetzner CX22). k3s v1.32.2, Calico 3.27.0, kube-bench 0.8.0. Last verified: April 25, 2026.