Multi-Cluster Deployment
Deploy applications across multiple Kubernetes clusters from a single control plane
Multi-Cluster Deployment
Wardn deploys to remote clusters from a single control plane. The destination.cluster field picks the target — the operator fetches that cluster's kubeconfig from the server on demand, so no remote credentials ever sit in the management cluster.
How It Works
┌──────────────────────────┐
│ Management cluster │
│ ┌────────────┐ │
│ │ Operator │──────────┼──▶ remote cluster (staging)
│ │ AppReconcile│ │ GET /internal/clusters/
│ └─────┬──────┘ │ {name}/kubeconfig
│ │ │
│ ▼ ├──▶ remote cluster (prod)
│ ┌──────────┐ │
│ │ Server │ │ kubeconfigs sealed with
│ │ (Postgres│ │ WARDN_CREDENTIALS_KEY
│ │ clusters │ │
│ └──────────┘ │
└──────────────────────────┘| Piece | Behavior |
|---|---|
| Registration | POST /api/v1/clusters — kubeconfig sealed (enc:v1: AES-256-GCM) into Postgres; no kubeconfig Secrets in the management cluster |
| Resolution | Operator calls GET /internal/clusters/{name}/kubeconfig at reconcile time, caches the client |
| Namespace allowlist | namespaces on the registered cluster constrains which destination.namespace values are valid |
Empty destination.cluster | Means the management cluster itself — the operator's own context |
Registering a Remote Cluster
Via the UI
- Navigate to Clusters
- Click Add Cluster
- Provide name, server URL (
https://required), and paste a kubeconfig - Test Connection validates connectivity without storing anything
- Save
Via the API
curl -X POST http://localhost:8080/api/v1/clusters \
-b cookies.txt -H "Content-Type: application/json" \
-d '{
"name": "staging",
"server_url": "https://staging.example.com:6443",
"kubeconfig": "apiVersion: v1\nkind: Config\n...",
"namespaces": ["staging", "default"]
}'The CLI has no cluster add command today — registration is API/UI only. Kubeconfigs using exec or auth-provider credential plugins are rejected (400); server_url must be https://.
Verify:
curl http://localhost:8080/api/v1/clusters -b cookies.txt | jq '.clusters[] | {name, status, version, node_count}'Deploying to a Remote Cluster
Set destination.cluster:
apiVersion: cd.wardn.space/v1alpha1
kind: App
metadata:
name: my-app-staging
namespace: org-my-org
spec:
source:
type: Git
git:
repoURL: https://github.com/myorg/my-app
path: manifests
destination:
cluster: staging # ← registered cluster name
namespace: staging # must be in the cluster's namespace allowlist
syncPolicy:
automated: true
prune: trueWhen destination.cluster is empty, the app deploys to the management cluster — where the operator runs.
Tutorial: Staging + Production
Step 1: Register Both Clusters
curl -X POST http://localhost:8080/api/v1/clusters -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"name":"staging","server_url":"https://staging.example.com:6443","kubeconfig":"…"}'
curl -X POST http://localhost:8080/api/v1/clusters -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"name":"production","server_url":"https://prod.example.com:6443","kubeconfig":"…","namespaces":["production"]}'Step 2: Create Environment-Specific Apps
staging.yaml
apiVersion: cd.wardn.space/v1alpha1
kind: App
metadata:
name: my-app-staging
namespace: org-my-org
spec:
source:
type: Git
git:
repoURL: https://github.com/myorg/my-app
branch: main
path: overlays/staging
destination:
cluster: staging
namespace: staging
syncPolicy:
automated: true
prune: trueproduction.yaml
apiVersion: cd.wardn.space/v1alpha1
kind: App
metadata:
name: my-app-production
namespace: org-my-org
spec:
source:
type: Git
git:
repoURL: https://github.com/myorg/my-app
branch: main
path: overlays/production
destination:
cluster: production
namespace: production
syncPolicy:
requiresApproval: true # production gatekubectl --kubeconfig k3s.yaml apply -f staging.yaml -f production.yamlStep 3: Promote Between Environments
Two ways to ship staging to production:
- GitOps — commit the production overlay bump (
kustomize edit set image),requiresApprovalstages it asHeld, thenPOST /apps/production/my-app-production/approve - Digest promote —
POST /api/v1/apps/staging/my-app-staging/promote?to=my-app-productioncopies the exact digest staging runs onto prod'sDeployop
Security Considerations
- Sealed at rest — kubeconfigs encrypt to
enc:v1:blobs keyed byWARDN_CREDENTIALS_KEYbefore Postgres - No exec credentials —
exec/auth-providerplugins rejected; the server never runs commands from user-supplied config - Least privilege — use a service account with only the namespaces Wardn manages
- Namespace allowlist — caps which
destination.namespacevalues resolve on that cluster - Audit —
cluster.register/update/remove/test/shellall hit the audit log
Troubleshooting
Cluster shows disconnected
curl -X POST http://localhost:8080/api/v1/clusters/test -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"server_url":"https://staging.example.com:6443","kubeconfig":"…"}'Common causes: firewall on :6443, expired credentials, DNS failure — the response's error field names it.
Sync fails with 403 namespace errors
destination.namespace isn't in the cluster's namespaces allowlist — update the cluster registration or pick an allowed namespace.
RBAC errors on the remote cluster
The registered kubeconfig needs write access to the destination namespace. Minimum a namespaced edit role:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: wardn-deployer
namespace: production
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: edit
subjects:
- kind: ServiceAccount
name: wardn
namespace: wardn-deployerRelated
- Clusters API — registration, probing, shell pods
- Configuration —
destination.clusterfield reference - First App Guide — basic deployment tutorial