SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor · Updated 2026-08-20

Harden a single-node k3s cluster on a VPS

What a fresh k3s install leaves open on a VPS: port 6443, the kubelet on 10250, kubeconfig mode, NodePorts your firewall never sees, and privileged pods.

What a single-node k3s cluster exposes on day one

A single-node k3s cluster on a public VPS is exposed in five specific places the day after the one-line installer finishes: the Kubernetes API server on TCP 6443, the kubelet on TCP 10250, the kubeconfig file sitting on disk, the NodePort range your firewall cannot see, and any pod allowed to ask for privileged or hostPath. Each one has a fix that takes minutes. This guide assumes k3s is already running, so if it is not, start with a single-node k3s install on a VPS and come back afterwards.

Look at what is listening before you change anything.

sudo ss -tulpn | grep -E '6443|10250|10256|8472'

A default install shows 6443 (the API server), 10250 (the kubelet), 10256 (the kube-proxy health check) and 8472/udp (the flannel overlay, which uses VXLAN, virtual extensible LAN). k3s binds to 0.0.0.0 by default, so every one of those sits on your public address and not only on loopback.

Why port 6443 is the whole cluster

Anything that can authenticate to port 6443 with admin rights can create a pod, and a pod can be root on the host. Port 6443 is the door to the machine.

An open 6443 is not an instant compromise, because Kubernetes does not accept passwords. It wants a client certificate or a bearer token. Two things are still true.

First, the API server answers some requests with no credential at all. Default Kubernetes RBAC (role-based access control) binds the group system:unauthenticated to a role named system:public-info-viewer, which permits /version, /healthz, /livez and /readyz. From another machine:

curl -sk https://YOUR_SERVER_IP:6443/version

That returns your exact Kubernetes version, which is the input to a CVE (common vulnerabilities and exposures) search and the reason a scanner decides your box is interesting. Anything past those paths is refused, and the refusal names you:

forbidden: User "system:anonymous" cannot get path "/api"

Second, every API server bug is remotely reachable while that port is open. Patching stops being optional at that point.

The cheap fix is a firewall rule. ufw needs more than one line here, because k3s routes cluster traffic through the same kernel.

sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow from 203.0.113.10 to any port 6443 proto tcp
sudo ufw allow from 10.42.0.0/16 to any
sudo ufw allow from 10.43.0.0/16 to any
sudo ufw enable

The last two rules come straight from the k3s documentation. 10.42.0.0/16 is the default pod network and 10.43.0.0/16 is the default service network, and without them ufw drops cluster-internal traffic, so pods lose the API server and each other. The k3s example allows 6443 from everywhere; replacing that with your own address is the change worth making. If ufw is new to you, the ufw firewall basics for a VPS covers the default policies this depends on.

The k3s docs are blunt about the overlay port: "The VXLAN port on nodes should not be exposed to the world as it opens up your cluster network to be accessed by anyone." A default deny policy on incoming traffic handles that without naming the port.

The stronger fix is to stop reaching the API over the public address at all and use a VPN or mesh address instead. The server certificate must list the address you connect to, so add it as a SAN (subject alternative name) in /etc/rancher/k3s/config.yaml:

tls-san:
  - 10.8.0.1
  - k3s.example.com
secrets-encryption: true
sudo systemctl restart k3s
sudo k3s secrets-encrypt status

secrets-encryption: true encrypts Secret objects in the datastore. The k3s docs note that "Secrets-encryption cannot be enabled on an existing server without restarting it", and Secrets written before the change keep their old form until you run sudo k3s secrets-encrypt reencrypt. Be clear about what this buys. It protects a datastore file copied off a backup. It does nothing against someone who can talk to the API server, because the API server decrypts Secrets for whoever is allowed to read them. The same distinction runs through any self-hosted secret store, which is why a hardening pass on Vaultwarden spends its time on the admin token and the backup file rather than on the encryption itself.

Why the kubelet on port 10250 matters

The kubelet is the agent that starts containers. Its API on port 10250 lists pods and runs commands inside them. A kubelet that accepts anonymous requests is a remote shell into every workload on the machine.

Check yours:

curl -sk https://127.0.0.1:10250/pods | head -c 60

A current k3s answers Unauthorized, because the kubelet asks the API server to authenticate and authorise every caller. If a JSON pod list comes back instead, anonymous access is on, and anyone who can reach port 10250 can read from and execute in your containers.

Close it from outside either way. On a single node the only client of the kubelet is the control plane on the same box, and that traffic enters through the loopback interface, which ufw accepts by default. Denying 10250 from the internet costs you nothing. If you arrived here from a broken kubectl top or a metrics-server that will not settle, the causes are collected in kubelet port 10250 errors.

Your kubeconfig is a cluster admin credential

k3s writes /etc/rancher/k3s/k3s.yaml owned by root with mode 600. The documentation states the consequence of changing that: "The kubeconfig file is owned by root, and written with a default mode of 600. Changing the mode to 644 will allow it to be read by other unprivileged users on the host."

Read that as: mode 644 makes every local account a cluster administrator. Plenty of walkthroughs suggest exactly that, usually as --write-kubeconfig-mode 644, so that kubectl works without sudo. It works by handing the admin credential to anyone with a shell.

Copy the file to one user instead.

mkdir -p ~/.kube
sudo install -o "$USER" -g "$USER" -m 600 /etc/rancher/k3s/k3s.yaml ~/.kube/config
kubectl get nodes

Then confirm the original is still tight:

stat -c '%a %U:%G' /etc/rancher/k3s/k3s.yaml

600 root:root is the answer you want. That file holds a client certificate for a member of system:masters, the group the API server treats as unconditionally allowed, so RBAC rules are never consulted for it. Kubernetes has no certificate revocation list, which means a leaked copy stays valid until you rotate the cluster certificate authority. Treat it like an SSH private key and keep the set of accounts that can reach it small, which is the same argument as least privilege user accounts on a VPS.

Your firewall does not see NodePort traffic

A type: NodePort service opens a port between 30000 and 32767 on every address the node holds, including the public one. A type: LoadBalancer service goes further on k3s: ServiceLB, the bundled load balancer, schedules a small pod per service in kube-system that claims the service port directly on the host.

kubectl -n kube-system get pods | grep svclb
sudo ss -tulpn | grep -E ':3[0-2][0-9]{3}'

Now the part that surprises people. Block that port with ufw and it keeps answering.

sudo ufw deny 30080/tcp
curl http://YOUR_SERVER_IP:30080

The page still loads, because of where the packet travels. kube-proxy writes DNAT (destination network address translation) rules into the nat table's PREROUTING chain, and PREROUTING runs before any filtering decision is made. The destination becomes a pod address, which is not the host, so the kernel sends the packet down the FORWARD chain and never through INPUT. ufw's rules live in INPUT. The packet never meets them. The jump order is visible:

sudo iptables -S PREROUTING -t nat | head
sudo iptables -S FORWARD | head

KUBE-SERVICES sits at the top of PREROUTING, and the Kubernetes jumps in FORWARD sit above ufw's own chains. This is the same mechanism that lets Docker publish ports past ufw, and the answers are the same ones.

  • Filter at your provider's network firewall. It runs in front of the machine and does not care how your kernel routes.
  • Skip NodePort and LoadBalancer. Leave services at ClusterIP and reach them with kubectl port-forward over the SSH session you already have.
  • Expose one ingress on 80 and 443, and nothing else.
  • Narrow the range with service-node-port-range under kube-apiserver-arg, so an accidental NodePort lands somewhere you are watching.

ufw is still worth running. It governs traffic addressed to the host itself, which is SSH and the API server. It simply does not police pod traffic, and expecting it to is how a database ends up reachable.

A pod with hostPath or privileged is root on your VPS

Containers are ordinary processes on your kernel with a restricted view of it. Several pod fields hand that restriction back.

  • securityContext.privileged: true gives the container every Linux capability and access to host devices.
  • hostPath mounts a host directory into the pod. A pod that mounts / read-write can append a key to /root/.ssh/authorized_keys.
  • hostPID: true puts the container in the host process namespace, where nsenter against PID 1 opens a host shell.
  • hostNetwork: true puts it on the host network stack, where it binds host ports and reaches services bound to loopback.

So "who can create pods here" is the same question as "who is root on this VPS". Any ServiceAccount with create on pods in any namespace is root-equivalent unless something rejects the pod first.

That something is Pod Security admission, built into the API server. The quick version is a label per namespace and needs no restart.

kubectl label namespace default \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/enforce-version=latest \
  pod-security.kubernetes.io/warn=restricted

baseline rejects all four fields above. restricted goes further and requires a non-root user, a seccomp (secure computing mode) profile, no privilege escalation and capabilities dropped to ALL, which breaks many published charts. Enforcing baseline while only warning about restricted lets you read what would break before you commit to it.

Prove it works:

kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: pstest
spec:
  containers:
  - name: app
    image: busybox
    command: ["sleep", "60"]
    securityContext:
      privileged: true
EOF

The API server refuses it, and says which field it refused:

Error from server (Forbidden): error when creating "STDIN": pods "pstest" is forbidden: violates PodSecurity "baseline:latest": privileged (container "app" must not set securityContext.privileged=true)

For a cluster-wide default rather than a label on each namespace, k3s documents an admission configuration file at /var/lib/rancher/k3s/server/psa.yaml:

apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: PodSecurity
  configuration:
    apiVersion: pod-security.admission.config.k8s.io/v1beta1
    kind: PodSecurityConfiguration
    defaults:
      enforce: "baseline"
      enforce-version: "latest"
      warn: "restricted"
      warn-version: "latest"
    exemptions:
      namespaces: [kube-system]

Point the API server at it in /etc/rancher/k3s/config.yaml, then restart k3s:

kube-apiserver-arg:
  - 'admission-control-config-file=/var/lib/rancher/k3s/server/psa.yaml'

The kube-system exemption is not optional. k3s's own ServiceLB pods claim host ports, which baseline forbids, so leaving kube-system out of the list means those pods are rejected the next time something recreates them. Keep a second SSH session open when you restart k3s after an admission change.

One bonus while you are here: k3s ships a network policy controller and enables it by default, so NetworkPolicy objects take effect on this cluster with nothing extra installed. That is not true of every Kubernetes distribution, and it is the tool for stopping one compromised pod from reaching the rest.

Remove the bundled components you do not use

The installer deploys a set of add-ons. Each one adds a listener and one more thing to patch. --disable accepts these values: coredns, servicelb, traefik, local-storage, metrics-server, runtimes.

Keep coredns. Nothing in the cluster resolves a name without it. The rest are choices. In /etc/rancher/k3s/config.yaml:

disable:
  - traefik
  - servicelb
disable-helm-controller: true
sudo systemctl restart k3s
kubectl get pods -A

k3s deletes components you disable, so the traefik pods and the svclb- pods disappear on their own. Know the consequences first. With servicelb gone, every type: LoadBalancer service stays at <pending> forever, because nothing assigns it an address. With traefik gone there is no ingress controller, so Ingress objects do nothing at all. Disable them when you serve traffic another way, such as a reverse proxy on the host, and leave them alone when you use them. disable-helm-controller: true removes the controller that watches HelmChart resources, which is a privileged component you are not using if you run helm yourself.

Stop automounting the default ServiceAccount token

Every pod gets a ServiceAccount token at /var/run/secrets/kubernetes.io/serviceaccount/token unless you say otherwise. The default ServiceAccount holds no RBAC permissions, so the token alone does not achieve much. What it gives an attacker inside a compromised container is a valid credential and a reachable API server, which is step one of most cluster escalation writeups.

The k3s hardening guide turns it off per namespace:

kubectl patch serviceaccount --namespace default default --patch '{"automountServiceAccountToken": false}'
kubectl patch serviceaccount --namespace kube-node-lease default --patch '{"automountServiceAccountToken": false}'
kubectl patch serviceaccount --namespace kube-public default --patch '{"automountServiceAccountToken": false}'

Check that it took. Give the pod a few seconds to start first.

kubectl run t --image=busybox --restart=Never --command -- sleep 30
kubectl exec t -- ls /var/run/secrets/kubernetes.io/serviceaccount

The path is gone, so ls prints No such file or directory. A workload that genuinely needs API access sets automountServiceAccountToken: true in its own pod spec, so nothing is locked out permanently. This is a small win by itself. The larger one is not granting a workload a ServiceAccount with real permissions, and you can see what a token is currently worth:

kubectl auth can-i --list --as=system:serviceaccount:default:default

Resource limits, so one pod cannot take the cluster down

On a single node the control plane and your workloads share one kernel and one pool of memory. A pod that leaks memory does not always die alone. The kernel's OOM (out of memory) killer picks its victim by a score weighted towards large processes, and k3s is a large long-lived process, so what disappears can be the cluster instead of the pod. Nothing then restarts your workloads, because the thing that restarts workloads is the thing that died.

A LimitRange fills in limits for pods that set none:

apiVersion: v1
kind: LimitRange
metadata:
  name: defaults
  namespace: default
spec:
  limits:
  - type: Container
    default:
      cpu: 500m
      memory: 512Mi
    defaultRequest:
      cpu: 50m
      memory: 128Mi

A ResourceQuota caps what the whole namespace can claim:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cap
  namespace: default
spec:
  hard:
    limits.cpu: "3"
    limits.memory: 3Gi
    pods: "20"

Then reserve room for k3s itself in /etc/rancher/k3s/config.yaml:

kubelet-arg:
  - 'system-reserved=cpu=250m,memory=512Mi'

The difference shows up in two places. A container killed for passing its own limit reports Reason: OOMKilled under Last State in kubectl describe pod, and the rest of the node carries on. A node that ran out of memory entirely leaves a Killed process line in dmesg and usually takes neighbours with it. The first is your limit doing its job. The second is what limits exist to prevent.

Scan manifests in CI and the k3s cluster on a schedule

Scanning belongs in two places, and they catch different problems. Install Trivy first. The project ships a Debian package with every release, and 0.74.0 was current in August 2026, so check the releases page for a newer version before you pin this into automation.

sudo apt-get install -y wget
wget -q https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.deb
sudo dpkg -i trivy_0.74.0_Linux-64bit.deb
trivy --version

The first place is your manifests, before they reach the cluster.

trivy fs --scanners misconfig --severity HIGH,CRITICAL --exit-code 1 ./deploy

--exit-code 1 fails the CI (continuous integration) job on a finding. Each result names the failing field and its severity, so a privileged: true you did not mean to commit fails the build instead of reaching the API server. A finding you have decided to accept goes in a .trivyignore file, which keeps that decision in git beside the manifest that caused it.

The second place is the running cluster, on a schedule.

trivy k8s --compliance=k8s-cis-1.23 --report summary

One honesty note on that command. trivy k8s deploys a node collector pod that needs host access to inspect node-level settings. On a cluster where you have just started rejecting privileged pods, that is worth noticing rather than working around. trivy k8s --report summary --disable-node-collector skips the collector and loses the node-level checks with it.

kube-bench covers the host side: file permissions and process flags, which is where most of the CIS (Center for Internet Security) benchmark actually lives. It ships a k3s profile. Take the upstream Job manifest and adjust it.

curl -sfL -o kube-bench-job.yaml https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml

Change the container command to ["kube-bench", "--benchmark", "k3s-cis-1.7"], and swap the /etc/kubernetes and /var/lib/etcd mounts for /etc/rancher and /var/lib/rancher, because that is where k3s keeps its files. Then:

kubectl apply -f kube-bench-job.yaml
kubectl logs -l app=kube-bench --tail=-1

Notice what that Job is: hostPID: true plus host directory mounts, which is exactly the pod shape you started rejecting a section ago. Run it in the exempted kube-system namespace, read the output, then kubectl delete job kube-bench. A scanner that must be privileged is no reason to stop banning privileged workloads.

Image contents are a separate question. trivy image ghcr.io/example/app:1.4 reads the package database inside an image and lists what is known to be vulnerable, the same job as when you check your server for known CVEs.

Now the honest part about scanner output. A single-node hobby cluster will fail a long list of CIS controls, and most of those failures are both correct and irrelevant to you. The benchmark is written for a multi-node, multi-tenant cluster: etcd on separate hosts, audit logs shipped off the machine, a separate kubelet certificate authority, admission plugins for a compliance regime you are not subject to. k3s deliberately runs the control plane as a single process with one config file, so controls that check the permissions of a kube-scheduler manifest file cannot pass, because no such file exists.

Read the failures in this order and stop when the value runs out: file modes and ownership under /etc/rancher and /var/lib/rancher, anything mentioning anonymous or unauthenticated access, anything reporting a component bound to 0.0.0.0, and any container running as UID 0 with no reason to. The rest can wait for a second node or a second person with access. A hundred-line report you ignore is worth less than a five-line one you act on.

A k3s hardening order for a single node

  1. Set the ufw default incoming policy to deny, allow SSH, allow 6443 from your own address, and allow the pod and service networks.
  2. Copy the kubeconfig to your user at mode 600, and never set --write-kubeconfig-mode 644.
  3. Confirm the kubelet on 10250 refuses anonymous requests, and keep the port off the internet.
  4. Disable the bundled components you do not use, then restart k3s.
  5. Label your namespaces for Pod Security admission with enforce=baseline and warn=restricted.
  6. Turn off default ServiceAccount token automounting.
  7. Add a LimitRange and a ResourceQuota, and reserve CPU and memory for k3s.
  8. Put trivy fs --scanners misconfig in CI, and run a CIS scan monthly.

The host underneath still needs the same care as any other server, and k3s adds a wrinkle there. It was installed by a script rather than by apt, so apt upgrade never touches it. Keep the operating system patched on its own schedule with unattended upgrades on Ubuntu, and upgrade k3s deliberately by rerunning its installer with the channel or version you want. Two update paths on one machine are easy to forget, so write down which component follows which.

FAQ

Is it safe to expose the k3s API server on port 6443 to the internet?

It is not an open door by itself, because the API server requires a client certificate or a token and refuses everything else with forbidden: User "system:anonymous". Two risks remain. Anonymous callers can still read /version, which tells a scanner exactly which Kubernetes release to look up. And every future API server vulnerability becomes remotely reachable while the port is open. On a single-node cluster nothing outside needs 6443 except your own kubectl, so allow it from your address with sudo ufw allow from YOUR_IP to any port 6443 proto tcp and let the default deny policy handle the rest.

Why does my ufw rule not block a NodePort service?

Because the packet never reaches the chain your rule sits in. kube-proxy puts DNAT rules in the nat table's PREROUTING chain, which runs first and rewrites the destination to a pod address. The packet is then forwarded rather than delivered locally, so it traverses FORWARD and skips INPUT, where ufw's rules live. Confirm it with sudo iptables -S PREROUTING -t nat | head. Filter NodePorts at your provider's network firewall, or avoid type: NodePort and reach services with kubectl port-forward instead.

Should I run k3s with --write-kubeconfig-mode 644?

No. The k3s documentation spells out what it does: "Changing the mode to 644 will allow it to be read by other unprivileged users on the host." That file holds a client certificate for system:masters, so mode 644 makes every local account a cluster administrator. Copy it to one user instead with sudo install -o "$USER" -g "$USER" -m 600 /etc/rancher/k3s/k3s.yaml ~/.kube/config, and leave the original at 600 root:root.

Which kube-bench benchmark should I use for k3s?

Use k3s-cis-1.7. The kube-bench documentation states: "kube-bench includes benchmarks for Rancher K3S platform. To run this you will need to specify --benchmark k3s-cis-1.7 when you run the kube-bench command." Pass it explicitly, because auto-detection assumes a kubeadm layout while k3s keeps its files under /etc/rancher and /var/lib/rancher. Expect failures that do not apply to a single node, and act on file permissions and anonymous access first.

Will Pod Security admission break the bundled k3s components?

It will if you enforce it on kube-system. The ServiceLB pods that k3s creates for type: LoadBalancer services claim ports on the host, and baseline forbids host ports, so those pods are rejected the next time they are recreated. Exempt kube-system in the admission configuration file, or apply Pod Security only as labels on the namespaces you own. Start with enforce=baseline and warn=restricted so you can read what restricted would break before you turn it on.