Application Infrastructure Azure

k3s on Ubuntu 24.04 on Azure User Guide

| Product: k3s on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and operation of k3s on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. 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 rather than a lookalike, standard kubectl, standard manifests and standard Helm charts all work unchanged.

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 additionally 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, so 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, and if you genuinely need remote API access you 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 is included:

  • 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 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/cloudimg-smoke-workload.yaml

  • A cluster self check at /opt/k3s/cloudimg-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 allowing 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.

Step 1: Deploy from the Azure Portal

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

Step 2: Deploy from the Azure CLI

az group create --name k3s-rg --location eastus

az vm create \
  --resource-group k3s-rg \
  --name k3s-node \
  --image cloudimg: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

Step 3: Verify the k3s service and the pinned version

Connect over SSH and confirm the service is running and 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

The k3s service reporting active and enabled, k3s version v1.36.2+k3s1 with its Go build version, and the VERSION provenance file listing the pinned release tag alongside the verified sha256 digests of the k3s binary, the bundled cluster image set and the official installer script

Step 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.

The cluster node reporting Ready with the control plane role and the pinned k3s version alongside its containerd runtime, the three bundled system pods for CoreDNS, the local path provisioner and metrics server all Running, and the local path default StorageClass

Step 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/cloudimg-smoke-workload.yaml
sudo kubectl -n cloudimg-smoke rollout status deployment/cloudimg-smoke --timeout=120s
sudo kubectl -n cloudimg-smoke get deployment,pods

Expected output:

namespace/cloudimg-smoke created
deployment.apps/cloudimg-smoke created
deployment "cloudimg-smoke" successfully rolled out

NAME                             READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/cloudimg-smoke   1/1     1            1           3s

Confirm the pod is genuinely executing and that in cluster DNS resolves through CoreDNS:

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

Remove it again when you are done:

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

A digest pinned example Deployment applied to the cluster and successfully rolled out to one of one replicas reporting Available, and the running pod resolving the kubernetes default service through in cluster CoreDNS to the cluster service address

Step 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.

Step 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.

Now audit which ports are actually 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/cloudimg-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

The API server rejecting an anonymous request, a bogus bearer token and the version endpoint each with HTTP 401 while only the supervisor ping liveness probe answers 200, and the listening socket audit showing exactly three ports bound on all interfaces with the scheduler, controller manager, kube-proxy and container runtime all bound to loopback only

Step 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 it. 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

Only do this if you genuinely 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.

Step 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. Remember that the network security group still has to 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.

Tidy the example away 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

Step 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.

Step 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. Back that directory up, with the service stopped, if you need a restorable copy of the cluster.

Step 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 holding 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 by 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.

Step 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; cloudimg redistributes it unmodified under that licence.

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

Deploy on Azure

Find k3s on Ubuntu 24.04 LTS by cloudimg in the Azure Marketplace, or browse the full catalogue at www.cloudimg.co.uk/products/.

Need Help?

Contact cloudimg support at support@cloudimg.co.uk.