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=<uks-network-uuid>".
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 3m15s
I 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.pem
2. 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 <CA_B64> and <CERT_B64> with the values from step 2. Replace <INTERNAL_API_ENDPOINT> with the internal API server hostname and <NODE_NAME> 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=systemd
4. 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-uuid
5. 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)" \
--wait
Your 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 <NODE_NAME> used in the CSR and cloud-init script — the UKS CCM matches nodes to VMs by hostname.
6. Verify
kubectl get nodes -o wide
Notes
- 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 <host>:6443 </dev/null 2>/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.