Guides

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 │            │
│  └──────────┘            │
└──────────────────────────┘
PieceBehavior
RegistrationPOST /api/v1/clusters — kubeconfig sealed (enc:v1: AES-256-GCM) into Postgres; no kubeconfig Secrets in the management cluster
ResolutionOperator calls GET /internal/clusters/{name}/kubeconfig at reconcile time, caches the client
Namespace allowlistnamespaces on the registered cluster constrains which destination.namespace values are valid
Empty destination.clusterMeans the management cluster itself — the operator's own context

Registering a Remote Cluster

Via the UI

  1. Navigate to Clusters
  2. Click Add Cluster
  3. Provide name, server URL (https:// required), and paste a kubeconfig
  4. Test Connection validates connectivity without storing anything
  5. 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: true

When 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: true

production.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 gate
kubectl --kubeconfig k3s.yaml apply -f staging.yaml -f production.yaml

Step 3: Promote Between Environments

Two ways to ship staging to production:

  • GitOps — commit the production overlay bump (kustomize edit set image), requiresApproval stages it as Held, then POST /apps/production/my-app-production/approve
  • Digest promote — POST /api/v1/apps/staging/my-app-staging/promote?to=my-app-production copies the exact digest staging runs onto prod's Deploy op

Security Considerations

  • Sealed at rest — kubeconfigs encrypt to enc:v1: blobs keyed by WARDN_CREDENTIALS_KEY before Postgres
  • No exec credentials — exec/auth-provider plugins 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.namespace values resolve on that cluster
  • Audit — cluster.register/update/remove/test/shell all 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-deployer

On this page