Deployment guide

k3s

Overview

k3s is a fully conformant, CNCF-certified Kubernetes distribution packaged as a single self-contained binary. One systemd service brings up the entire control plane together with the container runtime, cluster networking, in-cluster DNS, a metrics pipeline, a local storage provisioner and a service load balancer. Because it is real Kubernetes, standard kubectl, manifests and Helm charts all work unchanged.

This guide covers the deployment and operation of k3s on Ubuntu 24.04 on Azure, using the SecureAnyCloud images on the Azure Marketplace.

The image installs a pinned k3s release, v1.36.2+k3s1. Every artifact is verified against the upstream project's published sha256sum-amd64.txt before it is used: the k3s binary, the bundled cluster image set, and the official installer script itself, which is fetched from the immutable release tag rather than a rolling URL.

The build also cross-checks upstream's live manifest against the digests recorded in the recipe, and stops if the release was ever re-cut. The exact version and every verified digest are recorded on the image at /opt/k3s/VERSION.

No cluster credential of any kind ships inside the image. Kubernetes security rests entirely on the cluster certificate authority, the client certificates it signs, the service account signing key and the node join token. Every one of those is destroyed before the image is captured, and the build refuses to produce an image if any of them survive.

They are regenerated from scratch the first time each virtual machine boots. No two deployments ever share a credential, and nothing an attacker could obtain from the image is of any use against your cluster.

The API server is not exposed to the internet by default. An exposed Kubernetes API server is total cluster compromise, so the network security group that ships with this image opens port 22 only. Port 6443 is never opened.

You administer the cluster over SSH, or tunnel the API to your workstation. If you need remote API access, open it deliberately to your own address range, as described in Step 8.

First boot needs no registry access. The complete k3s cluster image set ships inside the image as a checksum-verified bundle, so CoreDNS, metrics-server, the local path provisioner, the service load balancer and the example workload all start from local disk. Nothing can be delayed or broken by a registry outage or a rate limit.

What you get:

  • k3s v1.36.2+k3s1, pinned and checksum-verified, running as a single-node server under systemd.
  • kubectl, crictl and ctr on the system path, with kubectl preconfigured for the root user.
  • CoreDNS for in-cluster DNS, metrics-server for kubectl top, the local path provisioner as the default StorageClass, and ServiceLB so that type: LoadBalancer services work.
  • The full cluster image set, bundled and checksum-verified on the image, for a first boot that needs no registry.
  • A per-virtual-machine cluster certificate authority, node token and kubeconfig, generated on first boot and recorded in /root/k3s-credentials.txt (readable by root only).
  • A digest-pinned example workload at /opt/k3s/examples/sac-smoke-workload.yaml.
  • A cluster self-check at /opt/k3s/sac-verify.sh that proves the cluster is healthy and correctly locked down.
  • The Apache 2.0 licence notice, retained at /opt/k3s/LICENSE.

Prerequisites

  • An Azure subscription with permission to deploy virtual machines.
  • A network security group that allows inbound TCP 22 (SSH) from the addresses you trust. Do not open TCP 6443.
  • An SSH key pair for the azureuser administrative account.
  • A VM size of Standard_B2ms (2 vCPU, 8 GiB) or larger. The cluster runs on a 4 GiB machine, but 8 GiB leaves usable headroom once you schedule your own workloads.

Ports 6443 (the Kubernetes API server) and 10250 (the kubelet) are bound on all interfaces because a working Kubernetes node requires it, but neither is reachable from outside the virtual network unless you add a network security group rule yourself. Every other component, including the scheduler, the controller manager and the container runtime, is bound to loopback only.

1

Deploy from the Azure Portal

  • In the Azure Portal, choose Create a resource and search the Marketplace for k3s on Ubuntu 24.04 LTS by SecureAnyCloud.
  • Choose your subscription, resource group and region, and set the VM size to Standard_B2ms or larger.
  • Set the administrator username to azureuser and upload your SSH public key.
  • On the Networking tab, allow inbound 22 only, from the addresses you trust.
  • Select Review + create. The cluster is generated on first boot and is usually Ready within about a minute of the VM reporting Running.
2

Deploy from the Azure CLI

az group create --name k3s-rg --location eastus
az vm image terms accept --urn sac1647283583153:k3s-ubuntu-24-04:default:latest
az vm create \
  --resource-group k3s-rg \
  --name k3s-node \
  --image sac1647283583153:k3s-ubuntu-24-04:default:latest \
  --size Standard_B2ms \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard
# Open SSH only. There is deliberately no rule for 6443 here.
az vm open-port --resource-group k3s-rg --name k3s-node --port 22 --priority 1001
3

Verify the k3s service and the pinned version

Connect over SSH and confirm that the service is running and that the version is the pinned, checksum-verified release.

sudo systemctl is-active k3s.service
sudo systemctl is-enabled k3s.service
sudo k3s --version sudo cat /opt/k3s/VERSION

Expected output:

active enabled k3s version v1.36.2+k3s1 (01b6f04a) go version go1.26.4 k3s_version=v1.36.2+k3s1 k3s_release_tag=v1.36.2+k3s1 k3s_binary_sha256=65a55ec56c24eab44383086166ec620a491952b7e23941a49ddca6e8a4c4b4de k3s_airgap_bundle_sha256=6065608edb70f061570fa15c4cce4eeb31e5218eadbacad65de4a75d1de4dad6 k3s_install_sh_sha256=46177d4c99440b4c0311b67233823a8e8a2fc09693f6c89af1a7161e152fbfad k3s_license=Apache-2.0 k3s_disabled_components=traefik
4

Inspect the cluster

kubectl is already configured for the root user, so sudo kubectl works with no further setup.

sudo kubectl get nodes -o wide
sudo kubectl get pods -A sudo kubectl get storageclass

Expected output:

NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE CONTAINER-RUNTIME k3s-node Ready control-plane 3m7s v1.36.2+k3s1 10.0.0.21 Ubuntu 24.04.4 LTS containerd://2.3.2-k3s2 NAMESPACE NAME READY STATUS RESTARTS AGE kube-system coredns-5f5694d56b-6l9np 1/1 Running 0 3m2s kube-system local-path-provisioner-58d557dc48-pxd8q 1/1 Running 0 3m2s kube-system metrics-server-7c86f97b8d-gmx85 1/1 Running 0 3m2s NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE AGE local-path (default) rancher.io/local-path Delete WaitForFirstConsumer 3m3s

The node reports Ready with the control-plane role, and the three bundled system deployments are running. local-path is the default StorageClass, so a PersistentVolumeClaim with no class named will be satisfied from the node's own disk.

5

Run a real workload

A digest-pinned example workload ships on the image. Its container image comes from the bundled cluster image set, so applying it needs no registry access at all.

sudo kubectl apply -f /opt/k3s/examples/sac-smoke-workload.yaml
sudo kubectl -n sac-smoke rollout status deployment/sac-smoke --timeout=120s sudo kubectl -n sac-smoke get deployment,pods

Expected output:

namespace/sac-smoke created deployment.apps/sac-smoke created deployment "sac-smoke" successfully rolled out NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/sac-smoke 1/1 1 1 3s

Confirm that the pod is running and that in-cluster DNS resolves through CoreDNS:

POD=$(sudo kubectl -n sac-smoke get pods -l app=sac-smoke -o jsonpath='{.items[0].metadata.name}')
sudo kubectl -n sac-smoke exec "$POD" -- nslookup kubernetes.default.svc.cluster.local

Remove it again when you are done:

sudo kubectl delete -f /opt/k3s/examples/sac-smoke-workload.yaml
6

Your per-instance cluster credentials

Every credential this cluster uses was generated on this virtual machine's first boot. Nothing was inherited from the image.

sudo cat /root/k3s-credentials.txt
sudo stat -c '%a %U:%G %n' /root/k3s-credentials.txt /etc/rancher/k3s/k3s.yaml

Expected output (the node token is shown redacted here):

k3s.version=v1.36.2+k3s1 k3s.host=13.72.72.88 k3s.private_ip=10.0.0.21 K3S_URL=https://13.72.72.88:6443 k3s.local_api=https://127.0.0.1:6443 k3s.kubeconfig=/etc/rancher/k3s/k3s.yaml k3s.node_token=<redacted> k3s.cluster_ca_sha256=6F48CEEE79A5F516A5685852576CD8E9B76A127AED26B37FFE969D8151518D80 600 root:root /root/k3s-credentials.txt 600 root:root /etc/rancher/k3s/k3s.yaml

Both files are readable by root only. The node token joins additional agent nodes to this cluster and is equivalent to a cluster administrator credential, so treat it accordingly. The cluster CA fingerprint is unique to this machine; it is what makes a kubeconfig from any other machine useless here.

7

Confirm the cluster is locked down

The API server rejects every request that does not carry a valid credential. k3s disables anonymous authentication entirely, so this is stricter than a stock Kubernetes cluster: even the version and liveness endpoints require authentication.

curl -sk -o /dev/null -w 'no credential: HTTP %{http_code}\n' https://127.0.0.1:6443/api
curl -sk -o /dev/null -w 'bogus token: HTTP %{http_code}\n' -H 'Authorization: Bearer not-a-real-token' https://127.0.0.1:6443/api
curl -sk -o /dev/null -w 'version endpoint: HTTP %{http_code}\n' https://127.0.0.1:6443/version
curl -sk -w 'supervisor ping: %{http_code} ' https://127.0.0.1:6443/ping; echo

Expected output:

no credential: HTTP 401 bogus token: HTTP 401 version endpoint: HTTP 401 pongsupervisor ping: 200

Only the supervisor's /ping liveness probe answers without a credential, and it carries no cluster data.

Next, audit which ports are listening:

sudo ss -tlnp

Expected output:

LISTEN 0 4096 127.0.0.1:10010 users:(("containerd",...)) LISTEN 0 4096 127.0.0.54:53 users:(("systemd-resolve",...)) LISTEN 0 4096 0.0.0.0:22 users:(("sshd",...)) LISTEN 0 4096 127.0.0.53%lo:53 users:(("systemd-resolve",...)) LISTEN 0 4096 127.0.0.1:6444 users:(("k3s-server",...)) LISTEN 0 4096 127.0.0.1:10249 users:(("k3s-server",...)) LISTEN 0 4096 127.0.0.1:10248 users:(("k3s-server",...)) LISTEN 0 4096 127.0.0.1:10259 users:(("k3s-server",...)) LISTEN 0 4096 127.0.0.1:10258 users:(("k3s-server",...)) LISTEN 0 4096 127.0.0.1:10257 users:(("k3s-server",...)) LISTEN 0 4096 127.0.0.1:10256 users:(("k3s-server",...)) LISTEN 0 4096 *:6443 users:(("k3s-server",...)) LISTEN 0 4096 [::]:22 users:(("sshd",...)) LISTEN 0 4096 *:10250 users:(("k3s-server",...))

Exactly three ports are bound on all interfaces: 22 (SSH, the only port your network security group opens), 6443 (the API server) and 10250 (the kubelet, which kubectl logs, kubectl exec and metrics-server all require). Everything else, including the scheduler on 10259, the controller manager on 10257, the cloud controller manager on 10258, the kube-proxy endpoints on 10249 and 10256, the kubelet health endpoint on 10248, and the container runtime on 10010, is bound to loopback only and is unreachable from the network. Nothing listens on port 80 or 443, because the default ingress controller is deliberately not installed.

You can confirm all of this at any time with the cluster self check that ships on the image:

sudo /opt/k3s/sac-verify.sh

Expected output:

cluster OK: v1.36.2+k3s1 node=Ready CA-per-VM=6F48CEEE79A5(build=1A82FCB6A73E) anon-api=401 bogus-token=401 workload=Available pod-exec=OK dns=OK off-loopback-ports='22 6443 10250 ' traefik=disabled
8

Reach the cluster from your workstation

The API server is not reachable from outside the virtual network by default, and that is the correct posture. There are two supported ways to work with the cluster remotely.

Option A: an SSH tunnel (recommended, no port opened)

The kubeconfig is root-only, so first place a readable copy on the VM, copy it down, then delete the copy. Run these from your workstation, replacing <vm-ip> with your VM's address:

ssh azureuser@<vm-ip> 'sudo install -m 0644 -o azureuser /etc/rancher/k3s/k3s.yaml /tmp/k3s.yaml' scp azureuser@<vm-ip>:/tmp/k3s.yaml ~/.kube/k3s-azure.yaml
ssh azureuser@<vm-ip> 'rm -f /tmp/k3s.yaml'

Now forward the API port over SSH and use the kubeconfig as it is. It already points at 127.0.0.1:6443, which the tunnel serves:

ssh -L 6443:127.0.0.1:6443 azureuser@<vm-ip>
# then, in a second terminal: KUBECONFIG=~/.kube/k3s-azure.yaml kubectl get nodes

Option B: open the API deliberately to your own address range

Do this only if you need direct remote API access. Never use 0.0.0.0/0. Replace YOUR-CIDR with your own public address range:

az network nsg rule create \
  --resource-group k3s-rg \
  --nsg-name k3s-nodeNSG \
  --name allow-kube-api \
  --priority 1010 \
  --access Allow --protocol Tcp --direction Inbound \
  --source-address-prefixes YOUR-CIDR \
  --destination-port-ranges 6443

Then edit your copy of the kubeconfig, replacing https://127.0.0.1:6443 with your VM's own address on port 6443. The certificate already carries this machine's public and private addresses as subject alternative names, generated on first boot, so it validates without --insecure-skip-tls-verify.

9

Deploy your own applications

Everything you already know about Kubernetes applies. Deploy a manifest:

sudo kubectl create deployment my-app --image=nginx:1.29 --replicas=2 sudo kubectl rollout status deployment/my-app --timeout=120s sudo kubectl expose deployment my-app --port=80 --type=LoadBalancer sudo kubectl get deployment my-app

ServiceLB is enabled, so a type: LoadBalancer service is assigned the node's address and the port is bound on the host. The network security group must still allow that port before anything outside the virtual network can reach it.

Persistent storage works through the default local-path StorageClass:

sudo kubectl apply -f - <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: my-data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 1Gi EOF
sudo kubectl get pvc my-data

The claim stays Pending until a pod consumes it, because the provisioner uses WaitForFirstConsumer binding. That is expected.

Remove the example again:

sudo kubectl delete service my-app --ignore-not-found sudo kubectl delete deployment my-app --ignore-not-found sudo kubectl delete pvc my-data --ignore-not-found
10

Add worker nodes

This image is a single-node server, but it is a full k3s server, so you can join agents to it. Read the node token from the credentials file on the server. Then run the k3s installer on each agent, pointing it at the server's address over your virtual network:

sudo grep '^k3s.node_token=' /root/k3s-credentials.txt

On the agent machine, replacing <vm-ip> with the server's virtual network address and <your-token> with the node token you just read:

curl -sfL https://get.k3s.io | K3S_URL=https://<vm-ip>:6443 K3S_TOKEN=<your-token> sh -

The agent needs to reach the server on port 6443 and on UDP 8472 for the cluster overlay network. Allow both between your nodes inside the virtual network, not from the internet.

11

Managing the k3s service

Check the service and the cluster's own readiness report:

sudo systemctl is-active k3s.service
sudo systemctl show -p ActiveState,SubState --value k3s.service
sudo kubectl get --raw='/readyz?verbose' | tail -5

Expected output:

active active running [+]autoregister-completion ok [+]poststarthook/apiservice-openapi-controller ok [+]poststarthook/apiservice-openapiv3-controller ok [+]shutdown ok readyz check passed

For deeper diagnostics, use sudo systemctl status k3s.service for the unit summary, sudo journalctl -u k3s.service -n 100 --no-pager for the service log, and sudo kubectl get events -A --sort-by=.lastTimestamp for recent cluster events.

To stop the cluster and every container it is running, use sudo /usr/local/bin/k3s-killall.sh, which ships with k3s. Start it again with sudo systemctl start k3s.service.

Cluster state, including the embedded datastore, lives under /var/lib/rancher/k3s. To get a restorable copy of the cluster, stop the service and back up that directory.

12

Security recommendations

Never open port 6443 to 0.0.0.0/0. An exposed Kubernetes API server is total cluster compromise. Use the SSH tunnel in Step 8, or scope a network security group rule to your own address range.

Treat the node token as a cluster administrator credential. It is in a root-only file for that reason. Anyone who holds it can join a node and read every secret in the cluster.

Keep the kubeconfig root-only. If you copy it to a workstation, store it with 0600 permissions and never commit it to source control.

Use RBAC for anything beyond a single administrator. The kubeconfig on the image is a full cluster administrator. Create scoped ServiceAccounts and Roles for applications and for other people.

Do not run workloads as root. The example manifest shows the pattern: runAsNonRoot, a dropped capability set, no privilege escalation and the default seccomp profile.

Keep the OS patched. Unattended security upgrades are enabled on the image. k3s itself is pinned, so upgrade it deliberately, following the upstream upgrade documentation, rather than automatically.

Take backups before upgrading. Stop the service and copy /var/lib/rancher/k3s before any k3s version change.

13

Support and Licensing

k3s is licensed under the Apache License 2.0. The full notice is retained on the image at /opt/k3s/LICENSE. k3s is a CNCF hosted project and a certified Kubernetes distribution; SecureAnyCloud redistributes it unmodified under that licence.

The SecureAnyCloud image, its first-boot cluster bootstrap, its security hardening and this guide are provided by SecureAnyCloud with 24/7 support.

Deploy on Azure

Find k3s on Ubuntu 24.04 LTS by SecureAnyCloud in the Azure Marketplace, or browse the full catalogue at secureanycloud.com/products/.

Need Help?

Contact SecureAnyCloud support at sales@secureanycloud.com.

Deploy on Azure

Launch k3s on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure