UpCloud Kubernetes Service (UKS) provides a managed control plane. You scale capacity through node groups via the UKS API. But you can also attach individual cloud servers directly, outside the node group mechanism.
I tested this end-to-end on a UKS 1.35 cluster.
Why Bother?
Tools like Karpenter need this capability. Karpenter provisions servers one at a time, picking the most cost-efficient plan per workload. Without the ability to attach arbitrary VMs, Karpenter can't work with UKS.
And also because it's fun to experiment.
How It Works
Create a VM with POST /server, attach it to the cluster's private network, and start kubelet directly with --cloud-provider=external. The VM registers with the control plane as soon as kubelet starts. Cilium deploys its DaemonSet to the node, and pods schedule within a minute or two.
The key requirement: the VM must live in the same region as the cluster. UpCloud UKS SDN private networks don't span regions. A VM in fi-hel1 can't join a private network in de-fra1.
The Test
I ran two tests on a UKS 1.35 cluster with Cilium CNI and two existing nodes.
Test 1: Using only a public network
I created a CLOUDNATIVE-1xCPU-4GB VM using the UpCloud K8s 1.35 template with only public and utility interfaces. The cloud-init script ran kubeadm join against the public API server endpoint.
This did register the node, which kubectl get nodes showed, but it stayed NotReady. The API server's internal endpoint (:6443) was unreachable from outside the UKS network, and Cilium couldn't reach the control plane to initialize.
Test 2: attaching the private UKS network
I created a second VM with the UpCloud K8s 1.35 template. This time I attached it to the cluster's private network (10.0.0.0/24) using --network "type=private,network=.
The cloud-init script wrote the kubelet certificate and configuration files, then started kubelet directly with --cloud-provider=external and --register-with-taints=node.cluster.x-k8s.io/uninitialized:NoSchedule against the internal API server endpoint (:6443). The internal endpoint was immediately reachable over the private network.
Within 90 seconds the node reached Ready, with Cilium deployed:
NAME STATUS ROLES VERSION AGE
custom-worker-01 Ready <none> v1.35.6 3m15sI scheduled an nginx pod and verified connectivity.
Step-by-Step
1. Generate a Kubelet Client Certificate (manual CSR)
UKS does not enable bootstrap token authentication, so kubeadm join with a token won't work. Instead, generate a kubelet client certificate on your admin machine:
NODE_NAME="custom-worker-01"
openssl genrsa -out /tmp/kubelet-client.key 2048
openssl req -new -key /tmp/kubelet-client.key \
-subj "/CN=system:node:${NODE_NAME}/O=system:nodes" \
| base64 -w0 > /tmp/csr.b64
CSR_B64=$(cat /tmp/csr.b64)
cat <<EOF | kubectl create -f -
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
name: kubelet-${NODE_NAME}
spec:
request: ${CSR_B64}
signerName: kubernetes.io/kube-apiserver-client-kubelet
usages:
- digital signature
- key encipherment
- client auth
EOF
sleep 3
kubectl get csr kubelet-${NODE_NAME} \
-o jsonpath='{.status.certificate}' | base64 -d > /tmp/kubelet-client.crt
# Combine cert + key into one file (UKS convention)
cat /tmp/kubelet-client.crt /tmp/kubelet-client.key > /tmp/kubelet-client.pem2. Extract the Cluster CA and Encode Certificates
# Extract the cluster CA
kubectl config view --minify --raw \
-o jsonpath='{.clusters[0].cluster.certificate-authority-data}' \
| base64 -d > /tmp/ca.crt
# Base64-encode for embedding in cloud-init
CA_B64=$(base64 -w0 < /tmp/ca.crt)
CERT_B64=$(base64 -w0 < /tmp/kubelet-client.pem)
echo "CA_B64: ${CA_B64}"
echo "CERT_B64: ${CERT_B64}"3. Save the Cloud-Init Script for use at VM creation time
Replace and with the values from step 2. Replace with the internal API server hostname and with the intended node name (must match the UpCloud VM hostname).
#cloud-config
write_files:
- path: /etc/kubernetes/pki/ca.crt
encoding: base64
content: <CA_B64>
- path: /var/lib/kubelet/pki/kubelet-client-current.pem
encoding: base64
content: <CERT_B64>
permissions: "0600"
- path: /etc/kubernetes/kubelet.conf
content: |
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority: /etc/kubernetes/pki/ca.crt
server: https://<INTERNAL_API_ENDPOINT>:6443
name: default-cluster
contexts:
- context:
cluster: default-cluster
namespace: default
user: default-auth
name: default-context
current-context: default-context
users:
- name: default-auth
user:
client-certificate: /var/lib/kubelet/pki/kubelet-client-current.pem
client-key: /var/lib/kubelet/pki/kubelet-client-current.pem
- path: /var/lib/kubelet/config.yaml
content: |
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
authentication:
anonymous:
enabled: false
webhook:
cacheTTL: 0s
enabled: true
x509:
clientCAFile: /etc/kubernetes/pki/ca.crt
authorization:
mode: Webhook
webhook:
cacheAuthorizedTTL: 0s
cacheUnauthorizedTTL: 0s
cgroupDriver: systemd
clusterDNS:
- 10.96.0.10
clusterDomain: cluster.local
containerRuntimeEndpoint: unix:///var/run/containerd/containerd.sock
healthzBindAddress: 127.0.0.1
healthzPort: 10248
imageGCHighThresholdPercent: 85
logging:
verbosity: 0
resolvConf: /run/systemd/resolve/resolv.conf
rotateCertificates: true
staticPodPath: /etc/kubernetes/manifests
runcmd:
- mkdir -p /etc/kubernetes/pki /var/lib/kubelet/pki /etc/kubernetes/manifests
- systemctl daemon-reload
- >
kubelet \
--kubeconfig=/etc/kubernetes/kubelet.conf \
--config=/var/lib/kubelet/config.yaml \
--hostname-override=<NODE_NAME> \
--address=<VM_PRIVATE_IP> \
--cloud-provider=external \
--register-with-taints=node.cluster.x-k8s.io/uninitialized:NoSchedule \
--cgroup-driver=systemd4. Find Your UKS Network UUID
upctl kubernetes show <cluster-name>Look for Network UUID in the output. Or pull it from an existing node:
kubectl get node <existing-node> -o jsonpath='{.metadata.annotations}' | grep upcloud-vm-private-nw-uuid5. Create the VM
upctl server create \
--zone de-fra1 \
--hostname custom-worker-01 \
--title "custom-worker-01" \
--plan "CLOUDNATIVE-1xCPU-4GB" \
--os "01000000-0000-4000-8000-000160150100" \
--ssh-keys ~/.ssh/id_ed25519.pub \
--network "type=public" \
--network "type=utility" \
--network "type=private,network=<uks-network-uuid>" \
--enable-metadata \
--user-data "$(cat cloud-init.yaml)" \
--waitYour zone must match the cluster's zone. Use the UpCloud K8s template UUID (01000000-0000-4000-8000-000160150100) as the --os. The --hostname must match used in the CSR and cloud-init script — the UKS CCM matches nodes to VMs by hostname.
6. Verify
kubectl get nodes -o wideNotes
- Your VM must share the cluster's region. SDN private networks are region-scoped. No cross-region attachments.
- API server port changes by context. The internal endpoint serves on
6443. The public load balancer uses7443. VMs on the private network should use6443. - The CA hash in
cluster-infoConfigMap may not match. I extracted the actual cert hash from the API server:openssl s_client -connect.:6443 /dev/null | openssl x509 -pubkey -noout | sha256sum - Debian puts CNI plugins in the wrong place. Kubelet looks in
/usr/lib/cni. Cilium installs to/opt/cni/bin. The UpCloud K8s template handles this with a symlink. If you use a plain Debian template, addln -sf /opt/cni/bin /usr/lib/cnito your cloud-init. - Do not set
--node-ip. With--cloud-provider=external, the CCM handles node IPs. Setting--node-ipmanually interferes with CCM initialisation. The--addressflag in the script controls which IP kubelet binds to (the private SDN IP). Confirm your interface numbering by runningip addron an existing UKS node —eth3is typical but varies. - Bootstrap tokens won't work. UKS does not enable
--enable-bootstrap-token-authon the API server, and kubeadm v1.35 has a JWS retry loop bug. Use the CSR-based certificate approach above instead ofkubeadm join.