Kubernetes Cluster API infrastructure provider for Exoscale.
Cluster API (CAPI) runs inside a management cluster — any Kubernetes cluster you already have access to (k3s, GKE, EKS, kind, etc.). Three providers collaborate inside it to create workload clusters:
┌─────────────────────────────────────────────────────────┐
│ Cluster API Core │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Infrastructure │ │ Control Plane │ │ Bootstrap │
│ Provider │ │ Provider │ │ Provider │
│ (this repo) │ │ (kubeadm) │ │ (kubeadm) │
│ │ │ │ │ │
│ - Instances │ │ - etcd │ │ - cloud-init │
│ - Security Groups│ │ - API server │ │ - node join │
│ - Elastic IPs │ │ - Controllers │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
Because this provider is not published to the clusterctl registry, the kubeadm providers are installed via clusterctl init while this one is deployed separately using the manifests in config/.
ch-gva-2 · ch-dk-2 · de-fra-1 · de-muc-1 · at-vie-1 · at-vie-2 · bg-sof-1
- A running Kubernetes cluster to use as the management cluster, with
kubectlpointing to it - clusterctl
- An Exoscale account with an API key/secret and an SSH key uploaded in the target zone
clusterctl init --bootstrap kubeadm --control-plane kubeadmkubectl create ns cluster-api-provider-exoscale-system
kubectl create secret generic exoscale-credentials \
--from-literal=EXOSCALE_API_KEY=$EXOSCALE_API_KEY \
--from-literal=EXOSCALE_API_SECRET=$EXOSCALE_API_SECRET \
-n cluster-api-provider-exoscale-systemThe image is published to GHCR automatically on every push to main. Open config/default/kustomization.yaml and replace the newName / newTag fields with your image reference, then apply:
kubectl apply -k config/default
kubectl get pods -n cluster-api-provider-exoscale-systemModify the manifest in examples/my-cluster.yaml, replacing SSH_KEY with the name of your ssh key, then apply it.
kubectl apply -f examples/my-cluster.yamlThen, watch provisioning (~5–10 min)
kubectl get cluster,exoscalecluster,machinesExpected sequence:
ExoscaleClusterbecomes ready (security groups + EIP created)- control-plane VM boots and gets the EIP attached
- kubeadm initialises
- workers join
ClusterbecomesReady=true
Check provider logs if something seems stuck:
kubectl logs -n cluster-api-provider-exoscale-system \
deployment/cluster-api-provider-exoscale-controller-manager --container manager -fGet kubeconfig either:
- using generated secret
kubectl get secret my-cluster-kubeconfig -o jsonpath='{.data.value}' | base64 -d > my-cluster.kubeconfig- or using clusterctl
clusterctl get kubeconfig my-cluster > my-cluster.kubeconfigThen, check the Nodes of the workload cluster:
kubectl --kubeconfig=my-cluster.kubeconfig get nodesIf nodes stay NotReady, install a CNI plugin. Example with Flannel (will automate this later on):
kubectl --kubeconfig=my-cluster.kubeconfig apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.ymlFlannel uses VXLAN (UDP 8472). If overlay traffic is blocked, add that port to the node security group in the Exoscale portal.
kubectl delete cluster my-cluster| Field | Required | Description |
|---|---|---|
spec.zone |
Yes | Exoscale zone (e.g. ch-gva-2) |
spec.masterSecurityGroup |
No | Control plane security group name (default: <cluster-name>-master) |
spec.nodeSecurityGroup |
No | Worker security group name (default: <cluster-name>-node) |
| Field | Required | Description |
|---|---|---|
spec.zone |
Yes | Exoscale zone |
spec.instanceType |
Yes | Instance type, e.g. standard.medium |
spec.template |
Yes | OS template name or UUID |
spec.diskSize |
Yes | Root disk size in GB |
spec.sshKey |
Yes | SSH key name (must exist in Exoscale for that zone) |
spec.antiAffinityGroup |
No | Anti-affinity group name or UUID |
spec.ipv6 |
No | Enable IPv6 (default: false) |
Common instance types: standard.tiny (1c/1GB) · standard.small (2c/2GB) · standard.medium (2c/4GB) · standard.large (4c/8GB) · cpu.large (4c/4GB) · memory.large (4c/32GB).
The provider creates two security groups per cluster with the following baseline ingress rules (all from 0.0.0.0/0):
| Group | Ports |
|---|---|
| Master | 22 (SSH), 6443 (API server), 2379–2380 (etcd), 10250 (kubelet), 10257 (controller-manager), 10259 (scheduler) |
| Node | 22 (SSH), 10250 (kubelet), 30000–32767 (NodePort) |
For production clusters, restrict etcd and kubelet ports to internal CIDRs. CNI-specific ports (e.g. Flannel VXLAN UDP 8472) must be added manually.
- Go 1.23+
- controller-gen — regenerates CRDs and deepcopy methods after API type changes
- kubectl
# Build and test
go build -o bin/manager ./cmd/manager/
go test ./...
# Regenerate CRDs and RBAC after changing types in api/
go run sigs.k8s.io/controller-tools/cmd/controller-gen \
rbac:roleName=manager-role crd webhook paths="./..." \
output:crd:artifacts:config=config/crd/bases
# Regenerate deepcopy methods after changing types in api/
go run sigs.k8s.io/controller-tools/cmd/controller-gen \
object:headerFile="hack/boilerplate.go.txt" paths="./..."The container image is built and pushed to GHCR by the CI workflow on every push to main.
├── api/v1beta1/ # CRD types
├── cmd/manager/ # Entry point
├── internal/
│ ├── cloud/ # Exoscale API client wrapper
│ └── controller/ # Reconciliation logic
├── config/ # Kustomize manifests
└── .github/workflows/ # CI (image build + push)
| Symptom | Check |
|---|---|
ExoscaleCluster stays Ready=false |
kubectl describe exoscalecluster my-cluster → Conditions; usually a credentials issue |
Machine stays Ready=false |
kubectl get machine -o jsonpath='{.items[*].spec.bootstrap.dataSecretName}' — empty means the bootstrap provider is still generating the cloud-init script; wait a moment |
| "template not found" in logs | Template names are case-sensitive and zone-specific; check the Exoscale portal under Compute → Templates |
| "SSH key not found" in logs | SSH keys are zone-specific; ensure the key exists in the same zone as the machines |
Nodes NotReady after joining |
Install a CNI plugin (step 6) |
| Security group deletion fails | SGs with attached instances cannot be deleted; the controller retries once instances are removed |
Contributions are welcome! Please feel free to submit issues or pull requests.
Apache License 2.0.