Attach Custom Cloud Servers to UpCloud Managed Kubernetes

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   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 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=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 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

  1. Your VM must share the cluster's region. SDN private networks are region-scoped. No cross-region attachments.
  2. API server port changes by context. The internal endpoint serves on 6443. The public load balancer uses 7443. VMs on the private network should use 6443.
  3. The CA hash in cluster-info ConfigMap 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.
  4. 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, add ln -sf /opt/cni/bin /usr/lib/cni to your cloud-init.
  5. Do not set --node-ip. With --cloud-provider=external, the CCM handles node IPs. Setting --node-ip manually interferes with CCM initialisation. The --address flag in the script controls which IP kubelet binds to (the private SDN IP). Confirm your interface numbering by running ip addr on an existing UKS node — eth3 is typical but varies.
  6. Bootstrap tokens won't work. UKS does not enable --enable-bootstrap-token-auth on the API server, and kubeadm v1.35 has a JWS retry loop bug. Use the CSR-based certificate approach above instead of kubeadm join.
Kubernetes UpCloud cloud-init Networking Cilium