Application Infrastructure AWS

k3s Certified Kubernetes on AWS User Guide

| Product: k3s Certified Single-Node Kubernetes on AWS

Overview

This guide covers the deployment and operation of k3s on AWS using the cloudimg AWS Marketplace AMI. 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 instance 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 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 instance 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 AWS account with permission to launch EC2 instances

  • A security group allowing inbound TCP 22 (SSH) from the addresses you trust. Do not open TCP 6443.

  • An EC2 key pair for SSH access

  • An instance type of m5.large (2 vCPU, 8 GiB) or larger. 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 your VPC unless you add a security group rule yourself. Every other component, including the scheduler, the controller manager and the container runtime, is bound to loopback only.

Connecting to your instance

SSH to the instance as the default login user for your operating system variant, using your EC2 key pair. kubectl is already configured for the root user, so sudo kubectl works with no further setup.

OS variant SSH login user Example
Ubuntu 24.04 ubuntu ssh -i your-key.pem ubuntu@<instance-public-ip>

Step 1: Launch from the AWS Marketplace

  1. In the AWS Marketplace find k3s - Certified Single-Node Kubernetes - AMI by cloudimg and choose Continue to Subscribe, then Continue to Configuration and Continue to Launch.
  2. Choose an instance type of m5.large or larger.
  3. Select your EC2 key pair.
  4. For the security group, allow inbound TCP 22 only, from the addresses you trust. Do not open 6443.
  5. Launch. The cluster is generated on first boot and is usually Ready within about a minute of the instance reaching the running state.

Step 2: Launch from the AWS CLI

aws ec2 run-instances \
  --image-id <ami-id> \
  --instance-type m5.large \
  --key-name your-key \
  --security-group-ids <sg-with-ssh-only> \
  --metadata-options 'HttpTokens=required' \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=k3s-node}]'

The security group referenced above should open TCP 22 only. There is deliberately no rule for 6443.

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
ip-172-31-87-251   Ready    control-plane   2m5s   v1.36.2+k3s1   172.31.87.251   Ubuntu 24.04.4 LTS   containerd://2.3.2-k3s2

NAMESPACE     NAME                                      READY   STATUS    RESTARTS   AGE
kube-system   coredns-5f5694d56b-k8mc6                  1/1     Running   0          2m
kube-system   local-path-provisioner-58d557dc48-2f9wb   1/1     Running   0          2m
kube-system   metrics-server-7c86f97b8d-tsbp7           1/1     Running   0          2m

NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   2m

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           2s

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 instance'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=54.173.189.22
k3s.private_ip=172.31.87.251
K3S_URL=https://54.173.189.22: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=12D7534203DCDBEC39177F2B6FF03FCC72B97C5747C8A9A2F1B79D65F9B3A847

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 instance; 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

Exactly three ports are bound on all interfaces: 22 (SSH, the only port your 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=12D7534203DC(build=A631E6CB4515) anon-api=401 bogus-token=401 workload=Available pod-exec=OK dns=OK off-loopback-ports='22 6443 10250 ' traefik=disabled

The CA-per-VM fingerprint differs from the build fingerprint recorded on the image, which is the proof that this cluster's certificate authority was genuinely regenerated on first boot and is not the one baked at build time.

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 cluster self check reporting the node Ready, a per instance certificate authority that differs from the build image, the example workload Available and exactly three ports bound off loopback

Step 8: Reach the cluster from your workstation

The API server is not reachable from outside your VPC 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 instance, copy it down, then delete it. Run these from your workstation, replacing <public-ip> with your instance's address:

ssh ubuntu@<public-ip> 'sudo install -m 0644 -o ubuntu /etc/rancher/k3s/k3s.yaml /tmp/k3s.yaml'
scp ubuntu@<public-ip>:/tmp/k3s.yaml ~/.kube/k3s-aws.yaml
ssh ubuntu@<public-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 ubuntu@<public-ip>
# then, in a second terminal:
KUBECONFIG=~/.kube/k3s-aws.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-mgmt-cidr> with your own public address range and <your-sg-id> with your instance's security group:

aws ec2 authorize-security-group-ingress \
  --group-id <your-sg-id> \
  --protocol tcp --port 6443 --cidr <your-mgmt-cidr>

Then edit your copy of the kubeconfig, replacing https://127.0.0.1:6443 with your instance's own address on port 6443. The certificate already carries this instance'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 security group still has to allow that port before anything outside your VPC 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 VPC:

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

On the agent machine, replacing <instance-ip> with the server's private VPC address and <your-token> with the node token you just read:

curl -sfL https://get.k3s.io | K3S_URL=https://<instance-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 VPC, 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
[+]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 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. cloudimg is not affiliated with or endorsed by the k3s project, the CNCF or SUSE.

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

Deploy on AWS

Find k3s - Certified Single-Node Kubernetes - AMI by cloudimg in the AWS Marketplace, or browse the full catalogue at www.cloudimg.co.uk/products/.

Need Help?

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