RKE2 Hardening Guide: CIS Benchmarks and DoD STIGs in a Homelab

RKE2 Hardening Guide: CIS Benchmarks and DoD STIGs in a Homelab

RKE2 (Rancher Kubernetes Engine 2) was built with security as a first-class concern. It ships with hardened defaults — no anonymous auth on the API server, audit logging enabled, etcd encryption by default on newer versions. But “secure defaults” and “fully CIS/STIG compliant” are different standards.

This guide covers the practical hardening steps for RKE2 on Rocky Linux 9 to get as close to CIS Kubernetes Benchmark and DoD STIG compliance as possible. This is based on running this setup in a homelab that mirrors production security requirements.

Prerequisites

Before hardening, you need a working RKE2 cluster. This guide assumes:

  • Rocky Linux 9.x nodes
  • RKE2 v1.32.x
  • 3-node cluster (1 control plane, 2 workers)
  • Access to modify /etc/rancher/rke2/ on all nodes

Rocky Linux 9 Base OS Hardening

CIS benchmarks start with the OS. Before touching Kubernetes:

# Install the SCAP Security Guide for automated checking
dnf install -y scap-security-guide

# Run CIS Level 1 benchmark scan
oscap xccdf eval \
  --profile xccdf_org.ssgproject.content_profile_cis \
  --results scan-results.xml \
  --report scan-report.html \
  /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml

# Apply CIS remediation (CAUTION: test first)
# oscap xccdf generate fix ...

Key OS hardening steps that the scan will flag:

# 1. Disable unused filesystems
cat > /etc/modprobe.d/cis-hardening.conf << 'EOF'
install cramfs /bin/false
install freevxfs /bin/false
install jffs2 /bin/false
install hfs /bin/false
install hfsplus /bin/false
install squashfs /bin/false
install udf /bin/false
install usb-storage /bin/false
EOF

# 2. Secure /tmp with nodev, nosuid, noexec
systemctl enable tmp.mount
cat >> /etc/systemd/system/tmp.mount << 'EOF'
[Mount]
Options=mode=1777,strictatime,noexec,nodev,nosuid
EOF

# 3. Audit system calls
dnf install -y audit
systemctl enable --now auditd

# Add Kubernetes-specific audit rules
cat > /etc/audit/rules.d/kubernetes.rules << 'EOF'
-w /etc/rancher/rke2/ -p wa -k rke2-config
-w /var/lib/rancher/rke2/ -p wa -k rke2-data
-a always,exit -F arch=b64 -S execve -F path=/usr/local/bin/kubectl -k kubectl-usage
EOF

# 4. Disable core dumps
cat >> /etc/security/limits.conf << 'EOF'
* hard core 0
EOF
echo "kernel.core_pattern=|/bin/false" >> /etc/sysctl.d/99-cis.conf

# 5. Kernel hardening
cat > /etc/sysctl.d/99-kubernetes-cis.conf << 'EOF'
# Disable IPv4 forwarding for non-pod interfaces (RKE2 will set what it needs)
# Note: RKE2 sets net.ipv4.ip_forward = 1 — this is intentional
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1
kernel.randomize_va_space = 2
EOF

sysctl --system

RKE2 Server Configuration

The main hardening happens in /etc/rancher/rke2/config.yaml:

# /etc/rancher/rke2/config.yaml (control plane nodes)

# CIS profile — enables a predefined set of hardened defaults
profile: cis  # This sets many CIS benchmark settings automatically

# Audit logging to file (required for STIG)
kube-apiserver-arg:
  # Authentication
  - "anonymous-auth=false"
  - "authentication-token-webhook-cache-ttl=0"

  # Authorization
  - "authorization-mode=Node,RBAC"

  # Audit logging (STIG requirement)
  - "audit-log-path=/var/lib/rancher/rke2/server/logs/audit.log"
  - "audit-log-maxage=30"
  - "audit-log-maxbackup=10"
  - "audit-log-maxsize=100"
  - "audit-policy-file=/etc/rancher/rke2/audit-policy.yaml"

  # Admission controllers
  - "enable-admission-plugins=NodeRestriction,PodSecurity,ServiceAccount"

  # TLS settings
  - "tls-min-version=VersionTLS12"
  - "tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"

  # Secrets encryption
  - "encryption-provider-config=/etc/rancher/rke2/encryption-config.yaml"

  # Deny running privileged containers at namespace level
  - "admission-control-config-file=/etc/rancher/rke2/admission-config.yaml"

  # Request timeout
  - "request-timeout=60s"

kube-controller-manager-arg:
  - "terminated-pod-gc-threshold=10"
  - "use-service-account-credentials=true"
  - "service-account-private-key-file=/var/lib/rancher/rke2/server/tls/service.key"
  - "bind-address=127.0.0.1"  # Only listen on localhost (CIS 1.3.7)

kube-scheduler-arg:
  - "bind-address=127.0.0.1"  # Only listen on localhost (CIS 1.4.2)

kubelet-arg:
  - "anonymous-auth=false"
  - "authorization-mode=Webhook"
  - "client-ca-file=/var/lib/rancher/rke2/server/tls/server-ca.crt"
  - "read-only-port=0"  # Disable unauthenticated read-only port (CIS 4.2.4)
  - "protect-kernel-defaults=true"  # Ensure kernel defaults are not overridden
  - "event-qps=0"      # Disable event rate limiting in some profiles
  - "streaming-connection-idle-timeout=5m"
  - "rotate-certificates=true"
  - "seccomp-default=true"  # Enable seccomp by default (requires k8s 1.27+)
  - "make-iptables-util-chains=false"  # Use iptables without modification chains

# etcd configuration
# etcd runs on control plane nodes; ensure it only listens on localhost or specific interface
etcd-arg:
  - "listen-metrics-urls=http://127.0.0.1:2381"  # Restrict metrics port

Audit Policy

# /etc/rancher/rke2/audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Don't log read-only requests from system components
  - level: None
    users:
    - system:kube-proxy
    verbs:
    - watch
    resources:
    - group: ""
      resources:
      - endpoints
      - services
      - services/status

  # Don't log kubelet authentication/authorization decisions
  - level: None
    userGroups:
    - system:nodes
    verbs:
    - get
    resources:
    - group: ""
      resources:
      - nodes
      - nodes/status

  # Log all pod exec/attach/portforward at Request level
  - level: Request
    verbs:
    - create
    resources:
    - group: ""
      resources:
      - pods/exec
      - pods/attach
      - pods/portforward

  # Log secret access at Metadata level (don't log secret contents)
  - level: Metadata
    resources:
    - group: ""
      resources:
      - secrets
      - configmaps
    - group: authentication.k8s.io
      resources:
      - tokenreviews

  # Log all other requests at Metadata level
  - level: Metadata
    omitStages:
    - RequestReceived

Pod Security with Namespace Labels

Kubernetes Pod Security Admission replaced PodSecurityPolicies:

# Label namespaces with the appropriate security level
# Apply to all namespaces that don't need privileged access
kubectl label namespace production \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/warn=restricted

# For system namespaces that need privileged (kube-system, flux-system, etc.)
kubectl label namespace kube-system \
  pod-security.kubernetes.io/enforce=privileged \
  pod-security.kubernetes.io/audit=privileged \
  pod-security.kubernetes.io/warn=privileged

For your own workloads in restricted namespaces, pods need to be compliant:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  namespace: production
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: app
        image: myapp:latest
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop:
            - ALL
        volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: var-run
          mountPath: /var/run
      volumes:
      - name: tmp
        emptyDir: {}
      - name: var-run
        emptyDir: {}

Network Policies

Default deny all, then explicitly allow what’s needed:

# Default deny all ingress and egress in production
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress
---
# Allow DNS (required for everything)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - ports:
    - port: 53
      protocol: UDP
    - port: 53
      protocol: TCP

Running kube-bench

Verify your hardening with kube-bench:

# Run kube-bench on control plane
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-master.yaml
kubectl logs job/kube-bench-master | head -100

# Run on worker nodes
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-node.yaml
kubectl logs job/kube-bench-node

Expected output for a hardened cluster:

== Summary master ==
42 checks PASS
8 checks FAIL
2 checks WARN
1 checks INFO

The remaining failures are typically intentional trade-offs (like etcd on the same nodes as the control plane, which is a homelab/small cluster compromise).

Continuous Compliance Monitoring

Set up ongoing compliance checking:

# Deploy kube-bench as a CronJob for regular scans
apiVersion: batch/v1
kind: CronJob
metadata:
  name: kube-bench-scan
  namespace: kube-system
spec:
  schedule: "0 0 * * 0"  # Weekly
  jobTemplate:
    spec:
      template:
        spec:
          hostPID: true
          nodeSelector:
            node-role.kubernetes.io/control-plane: "true"
          containers:
          - name: kube-bench
            image: aquasec/kube-bench:latest
            command: ["kube-bench", "run", "--targets", "master,etcd"]
            volumeMounts:
            - name: var-lib-etcd
              mountPath: /var/lib/etcd
              readOnly: true
            - name: var-lib-kubelet
              mountPath: /var/lib/kubelet
              readOnly: true
            - name: etc-systemd
              mountPath: /etc/systemd
              readOnly: true
          restartPolicy: Never
          volumes:
          - name: var-lib-etcd
            hostPath:
              path: /var/lib/rancher/rke2/server/db/etcd
          - name: var-lib-kubelet
            hostPath:
              path: /var/lib/kubelet
          - name: etc-systemd
            hostPath:
              path: /etc/systemd

Conclusion

Hardening RKE2 to CIS Benchmark compliance involves layers: the OS, the Kubernetes configuration, Pod Security, and Network Policies. The profile: cis setting in RKE2’s config does a lot of the heavy lifting, but the audit logging, encryption at rest, and admission controller configuration require explicit setup.

For a homelab that mirrors production security requirements, this configuration provides a solid baseline. Some STIG requirements (like multi-factor authentication for cluster admin) require additional infrastructure (identity providers, hardware tokens) that go beyond what’s covered here.

Run kube-bench regularly. The compliance landscape changes, and what passes today may not pass with a new benchmark version. Treat security compliance like any other operational metric: automate its measurement and alert on regressions.

Share this post: LinkedIn Reddit WhatsApp Mastodon
Jesse Borden

Jesse Borden

Software Engineer with an interest in hands on learning

I have several years of professional Information Technology (IT) experience leading staff and projects within the Department of War (DOW). I have managed Service Desk, Web Application Development, and System Administration teams. My two greatest passions are learning and conti...