Namespaces separate tenants logically, but their pods still share nodes. A namespace is a logical partition inside one cluster. When tenants must not share hardware, give each tenant its own **pool of nodes**. This puts isolation at the machine level, not just the namespace level. It is the pattern agencies and MSPs (managed service providers) use for tenants that need a hard boundary. ## Why node-level isolation matters Sensitive tenants, compliance rules, and noisy neighbors all need the same fix: separate machines. Short of a separate cluster, a dedicated pool is the strongest boundary, because a tenant's pods run only on that tenant's nodes. ## Two levers: dedicated machines and sandboxed workloads Dedicating machines is one lever. The other is the runtime a workload runs under. The `secure` runtime class runs each pod inside its own lightweight virtual machine, so a container escape stops at the VM boundary. Together, the two levers let a sensitive tenant run sandboxed workloads on its own dedicated hardware. See [Run in the secure runtime](/docs/hetzner/apalla/workloads/runtime-classes/container-runtimes). ## One pool per tenant Give each tenant a pool with a tenant label (see [Label nodes and assign roles](/docs/hetzner/apalla/servers-and-nodes/labels/label-nodes-and-assign-roles)): ```yaml workers: machineDeployments: - class: workeramd64hcloud name: md-tenant-acme replicas: 3 metadata: labels: node.cluster.x-k8s.io/tenant: acme - class: workeramd64hcloud name: md-tenant-globex replicas: 2 metadata: labels: node.cluster.x-k8s.io/tenant: globex ``` [Estimate the cost of your tenant pools](https://syself.com/pricing/calculator) with the Syself pricing calculator. ## Steer each tenant's pods, and keep others off Pin a tenant's workloads to its nodes with a `nodeSelector` or [node affinity](/docs/hetzner/apalla/workloads/placement/node-affinity-and-selectors). A `nodeSelector` is a rule that keeps a pod on nodes carrying a given label: ```yaml spec: nodeSelector: node.cluster.x-k8s.io/tenant: acme ``` A label attracts but does not repel. A pod without a `nodeSelector` can still land on a tenant's node. Syself Autopilot does not support node taints (see [Taints and tolerations](/docs/hetzner/apalla/workloads/placement/taints-and-tolerations)), so the boundary is the pool label plus discipline on your side: give **every** workload a `nodeSelector`, so nothing lands on a tenant's pool by accident. For a boundary that does not depend on every pod being labelled correctly, put sensitive tenants on their own **bare-metal** pool, where the hardware itself is the separation. ## Dedicated bare-metal pools for sensitive tenants For a tenant that must be on physically separate hardware, not just a separate pod, give it a bare-metal pool. Bare-metal (Robot) servers are physical machines you own. A pool that only claims that tenant's hosts means no other tenant's pod can ever land on that hardware. First label the tenant's `HetznerBareMetalHost` objects (see [Add bare-metal servers](/docs/hetzner/apalla/servers-and-nodes/bare-metal/add-bare-metal-servers)): ```console $ kubectl label hbmh baremetal-1 tenant=acme ``` Then point the pool at those hosts with `workerHostSelectorBareMetal`: ```yaml - class: workeramd64baremetal name: md-tenant-acme-bm replicas: 2 variables: overrides: - name: workerHostSelectorBareMetal value: matchLabels: tenant: acme ``` This is a second, separate label mechanism from the tenant pool label: | Label | Lives on | Decides | | ------------------------------------------------- | ------------------------------ | ------------------------------------- | | `tenant: acme` (host selector) | `HetznerBareMetalHost` | Which physical machine joins the pool | | `node.cluster.x-k8s.io/tenant: acme` (pool label) | `Node`, once the machine joins | Where pods land | You need both: the host selector picks which hardware becomes Acme's, the pool label steers Acme's pods onto it. Without a host selector, any free `HetznerBareMetalHost` can be claimed by any bare-metal pool in the same namespace, including ones a different tenant's pool is scaling into. Set `workerHostSelectorBareMetal` on every bare-metal pool, not only the sensitive one. You can run several bare-metal pools in one cluster, each selecting a different set of hosts, so each sensitive tenant gets its own machines. Check `kubectl get machines -o wide`: a bare-metal machine shows an `hrobot://` provider ID, confirming it landed on Robot hardware and not a cloud VM. ### A dedicated host has no fallback A per-tenant selector gives you isolation, but self-healing then has no other host to use. If the tenant's server fails, the replacement claims the same machine again, so a hardware fault is still there after the reinstall. Alert on the hardware [node conditions](/docs/hetzner/apalla/servers-and-nodes/access/node-problem-detection-and-conditions), so you notice a broken server instead of the pool replacing it again and again. See [Self-healing does not repair hardware](/docs/hetzner/apalla/servers-and-nodes/maintenance/machine-health-checks-and-remediation#self-healing-does-not-repair-hardware). ## Failure isolation per pool For cloud pools, a Hetzner placement group keeps a pool's VMs on separate physical hosts. Without one, Hetzner can put two of a tenant's VMs on the same physical host, so one hardware failure takes both. `spread` is the only placement group type Hetzner Cloud supports. Declare a group under `spec.topology.variables`, then reference it by name from the tenant's pool: ```yaml variables: - name: hcloudPlacementGroups value: - name: acme-spread type: spread workers: machineDeployments: - class: workeramd64hcloud name: md-tenant-acme replicas: 3 variables: overrides: - name: workerMachinePlacementGroupNameHcloud value: acme-spread ``` Give each tenant pool its own group. Then one physical-host failure takes at most one node from one tenant, and a single failure never spans tenants. See [Spread nodes with placement groups](/docs/hetzner/apalla/servers-and-nodes/cloud/placement-groups) for how the groups are named and their limits. Bare-metal pools do not need this. A Robot server already is one physical host, so the dedicated bare-metal pool above already gives you that isolation. ## Cost attribution and hand-off Each tenant's capacity is its own pool of named machines, so you can work out the cost per tenant from the machine type and count in that pool, and scale each pool on its own. Keeping clusters on the customer's own Hetzner account makes the hand-off clean. The machines, and their bill, already sit with the customer. A dedicated pool isolates compute: which machine a tenant's pods run on. It does not stop a tenant's pods from talking to another tenant's pods over the network. It does not stop a tenant's team from viewing another tenant's namespace either. Add these two layers along the same tenant boundary when the tenant needs them: - **Network policy.** Put each tenant in its own namespace and add a [`NetworkPolicy`](/docs/hetzner/apalla/security/segment-with-network-policies) that blocks cross-namespace traffic. Pod-to-pod traffic is allowed by default until you write one. - **RBAC.** Give the tenant's own team access to only their namespace with an `AccessTemplate` and `ClusterPermission`, instead of a shared kubeconfig. See [Manage access and tenancy](/docs/hetzner/apalla/security/manage-access-and-tenancy). Node pool, namespace network policy, and RBAC together isolate compute, traffic, and access along the same tenant boundary. ## Where to go next - [Label nodes and assign roles](/docs/hetzner/apalla/servers-and-nodes/labels/label-nodes-and-assign-roles) - [Mix cloud and bare-metal pools](/docs/hetzner/apalla/servers-and-nodes/pools/mix-cloud-and-bare-metal) - [Size a cluster for cost](/docs/hetzner/apalla/servers-and-nodes/fleet/size-a-cluster-for-cost) - [Agencies and MSPs](/docs/hetzner/apalla/security/multi-tenant-isolation-for-agencies)