A prototype environment for running OpenClaw and similar AI agents behind a real isolation boundary
KubeClaw is a learning project, not production software. Before deploying anything, read
Project Status.
Agentic AI environments like OpenClaw execute arbitrary code with tool access – they can read files, spawn processes, and make network requests. Running such workloads on a local machine or an unsandboxed server is inherently unsafe:
Agents inherit host-level privileges and can access the entire filesystem, including credentials and private keys
Without network policy enforcement, agents can exfiltrate data or reach arbitrary endpoints
A misbehaving agent on a local machine has no isolation boundary; the blast radius is everything on the host
Containers without network controls only solve half the problem – an agent with unrestricted egress can still leak data
KubeClaw provides a fully automated Kubernetes cluster on Hetzner Cloud VPS servers where OpenClaw runs inside containers with strict network controls. Infrastructure is managed through OpenTofu and Ansible, the cluster uses Cilium CNI for eBPF-based network policies that enforce per-namespace egress rules (e.g., allowing only Anthropic API and messaging provider endpoints), and all access is routed through a Cloudflare Tunnel – no open ports, no public SSH, outbound-only connectivity.
How it works
OpenTofu provisions the infrastructure: private network, firewalls, SSH keys, and servers on Hetzner Cloud
Cloud-init configures each server on first boot: SSH hardening, fail2ban, UFW, NAT64/DNS64, and Kubernetes prerequisites
Ansible handles ongoing server management: updates, security hardening, and configuration changes
kubeadm bootstraps a standard Kubernetes cluster with Cilium CNI and Hetzner CSI for persistent storage
Cloudflare Tunnel provides secure, outbound-only SSH access without exposing any ports
Cilium network policies enforce per-namespace egress rules, restricting OpenClaw to only its required API endpoints
For the node roles, IP layout, and traffic flow behind this, see
Architecture.
Features
Container isolation – Agentic workloads run in Kubernetes pods, never on bare metal or your local machine
Cilium network policies – eBPF-based FQDN egress filtering limits what an agent can reach on the network
Cloudflare Tunnel – Secure SSH and service access without open ports; outbound-only connectivity
IPv6-only – No public IPv4 addresses required, NAT64/DNS64 for transparent IPv4 reachability
Admin Node – Temporary jump host with public IPv6 for initial setup (removable)
Scalable – Master + replica control nodes, 0 to n worker nodes, mixed server types
Custom SSH keys – Optionally use your own keys, with configurable key file prefix
Ansible-ready – Playbooks for updates, hardening, NAT64 configuration, and Kubernetes prerequisites
Debian 13 – Stable, Kubernetes-compatible OS
kubeadm – Standard Kubernetes bootstrapper for CKA certification preparation
What KubeClaw is, what it is not, and what you should not do with it.
KubeClaw is a prototype
KubeClaw is a learning project. Its objective is to explore what a secure,
scalable environment for running AI agents on Kubernetes actually requires –
by building one end to end and finding out where the difficulties are.
It is not production ready. It is not beta. There is no supported
release, no stability guarantee, and no security review.
What that means concretely
Status
Maturity
Prototype / experiment
Suitable for production
No
Suitable for handling real secrets or customer data
No
Security reviewed or audited
No
API / variable stability
None – variables and layouts change without notice
Support
None – issues may go unanswered
Breaking changes
Expected, without a deprecation period
Why it exists
Agentic AI workloads execute arbitrary code with tool access. Running them
without an isolation boundary is genuinely risky, and the interesting question
is what a correct boundary looks like: which network controls actually hold,
how much egress restriction is practical, what the operational cost is, and
where the design breaks under load.
KubeClaw is an attempt to answer those questions by construction. It combines
IPv6-only Hetzner infrastructure, OpenTofu and Ansible provisioning, kubeadm,
Cilium network policies with FQDN egress filtering, and Cloudflare Tunnel
access. Building the whole path surfaces problems that reading about it does
not.
A secondary objective is preparation for the CKA certification, which is why
the cluster uses kubeadm rather than a turnkey distribution.
What it is not
Not a product. There is no roadmap commitment and no support channel.
Not a reference architecture. Several decisions are made to be
instructive rather than optimal.
Not hardened. The security model documented here describes the intent
of the design. It has not been adversarially tested, and you should assume
gaps exist.
Not a template to fork for production. More mature approaches are
expected to follow in separate projects, informed by what this one gets
wrong.
If you are evaluating this for real work
Please do not deploy KubeClaw to handle production traffic, real credentials,
or third-party data. Read it, take the ideas, and build something you have
reviewed yourself. The infrastructure it provisions is real and costs real
money -- see [Cost Estimate](/kubeclaw/docs/reference/cost-estimate/)
before running `tofu apply`.
Current state
The infrastructure layer is functional: network, firewalls, SSH key handling,
cloud-init, and the admin/control/worker node roles all provision and come up.
NAT64/DNS64, the Ansible playbooks, and the documentation site are in place.
The Kubernetes and OpenClaw layers are documented as guides but are not yet
automated, and the Kubernetes-specific firewall rules are deliberately still
excluded from the OpenTofu configuration. See the
Roadmap for what comes next.
Versioning
Releases use v0.x and follow semantic versioning only loosely. While the
major version is 0, any release may break any interface. See the
changelog
for what changed.
2 - Quick Start
Provision the cluster end to end: prerequisites, Dev Container, OpenTofu, SSH, and the Cloudflare Tunnel.
This provisions real, billable infrastructure
KubeClaw is a [prototype](/kubeclaw/docs/project-status/) -- a learning
project, not production software. The steps below create servers on Hetzner
Cloud that cost real money, and the resulting cluster has not been security
reviewed. Do not use it for production traffic, real credentials, or
third-party data.
Cloudflare account with a configured domain (free tier is sufficient)
Local Machine
Docker and an IDE with Dev Container support (e.g. VS Code + Dev Containers extension)
That’s it. All project tools (OpenTofu, Ansible, SSH, cloudflared, Hugo,
Kubernetes clients, and AI assistants) are available in the Dev Container. No
local installation is required beyond Docker and an IDE with Dev Container
support.
Optional
cloudflared on your local machine for SSH via Cloudflare Tunnel (brew install cloudflared on macOS). It is included in the Dev Container, but is
useful on the host if you connect outside the container.
Dashlane or another password manager for storing SSH keys and API tokens securely.
1. Clone and prepare
git clone <repo-url>
cd kubeclaw
# Create the persistent SSH directory used by the aibox Dev Container.mkdir -p .aibox-home/.ssh
chmod 700 .aibox-home/.ssh
2. Open in Dev Container
Open the project in your IDE and start the Dev Container (e.g. VS Code: “Reopen in Container”).
All remaining commands run inside the Dev Container.
3. Configure
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your Hetzner API token and other settings
This creates the admin node (temporary jump host), control node, private network, and firewalls. The admin node is enabled by default for initial SSH setup.
This exports SSH keys from the OpenTofu state, generates ~/.ssh/config
entries, starts the ssh-agent with the cluster keys loaded, and builds
ansible/inventory.ini from the current state. The project override makes
.aibox-home/.ssh/ writable in the container, so these files persist across
container rebuilds without being committed.
Rerun after every infrastructure change
`generate-ansible-inventory.sh` reads the OpenTofu outputs. Run it again after
any `tofu apply` that adds or removes nodes -- never hand-edit
`ansible/inventory.ini`.
6. Connect to the control node
ssh control-node # Routes via admin node automatically
7. Install Cloudflare Tunnel
Creating the tunnel, routing SSH through it, and adding an Access policy are
covered step by step in the Cloudflare Tunnel Setup
guide. Once you have a tunnel token, install it one of two ways.
Option A: Automatic (recommended) – Set the tunnel token in terraform.tfvars before tofu apply:
cloudflare_tunnel_token="eyJ..."
Get the token from the Cloudflare Zero Trust Dashboard under Networks > Tunnels > Create/Configure. The tunnel auto-starts on boot and survives node recreation.
Option B: Manual – SSH to the control node and install manually:
sudo cloudflared service install <YOUR_TUNNEL_TOKEN>
8. Disable admin node
After the tunnel is working, disable the temporary admin node:
# terraform.tfvars
enable_admin_node=false
tofu apply
The master control node always keeps public IPv6 (required for cloudflared).
Understand KubeClaw’s architecture, network model, and security boundaries.
Start here to understand the design decisions behind KubeClaw before provisioning
infrastructure.
3.1 - Architecture
Node roles, private network layout, and how traffic reaches an IPv6-only cluster.
Overview
KubeClaw creates a secure, IPv6-only Kubernetes cluster on Hetzner Cloud. The design prioritizes security through network isolation: no public IPv4 addresses, SSH access exclusively via Cloudflare Tunnel, and per-namespace egress control with Cilium network policies.
The master control node always exists and serves as:
Kubernetes control plane – runs etcd, kube-apiserver, kube-scheduler, kube-controller-manager
Cloudflare Tunnel endpoint – runs cloudflared for SSH access from the internet
SSH gateway – all other nodes are reached through this node
The master node always has public IPv6 (required for cloudflared outbound connections) and accepts SSH from the private network and from localhost (for the Cloudflare Tunnel). The tunnel can be auto-configured via cloudflare_tunnel_token or installed manually.
Replica Control Nodes (control-02+, 10.0.0.3+)
Optional nodes for high-availability control plane. Same configuration as the master, minus cloudflared. IPs start at 10.0.0.3 and increment.
Worker Nodes (10.0.0.x, offset after replicas)
Optional compute nodes for running workloads. Workers have restricted connectivity:
Inbound: SSH from private network only
Outbound: DNS (port 53), HTTP (port 80), HTTPS (port 443), internal network only
No TCP forwarding – prevents workers from being used as jump hosts
Worker IPs start after the last replica control node.
Admin Node (10.0.0.254, temporary)
A temporary jump host with public IPv6, used only during initial setup before the Cloudflare Tunnel is configured. Created by default (enable_admin_node = true) and should be disabled after tunnel setup.
Network Design
Private Network (10.0.0.0/24)
All nodes communicate via a Hetzner private network. IP assignments:
Address
Node
10.0.0.2
Master control node
10.0.0.3+
Replica control nodes
10.0.0.x
Worker nodes (offset after replicas)
10.0.0.254
Admin node (temporary)
IPv6-Only
Nodes have no public IPv4 addresses. The master control node always has public IPv6 (required for cloudflared). Replica control nodes and workers can optionally have public IPv6 disabled via enable_public_ipv6 = false to air-gap them from the internet. NAT64/DNS64 provides transparent IPv4 reachability for accessing IPv4-only services (GitHub, container registries, package repos).
Traffic Flow
SSH access: Internet → Cloudflare Tunnel → Master control node (localhost:22) → Private network → Other nodes
Outbound (control nodes): Full outbound connectivity via IPv6 + NAT64
Outbound (workers): Restricted to DNS, HTTP/S only
Further Reading
DNS and NAT64 – how IPv6-only nodes reach IPv4 services, CoreDNS configuration, and Kubernetes DNS architecture
Security Model – firewall rules, SSH hardening, and Kubernetes-level security
3.2 - Security Model
The three security layers: Hetzner firewalls, host hardening, and Kubernetes network policies.
KubeClaw’s security is layered across three levels: Hetzner Cloud firewalls, host-level hardening, and Kubernetes network policies. See Architecture for the overall network design.
Firewall Rules (Hetzner)
Hetzner Cloud firewalls are the first line of defense. Each node role has its own firewall:
Control nodes: SSH from private network + localhost (for tunnel), ICMP from private network
Worker nodes: SSH from private network, ICMP from private network
Admin node: SSH from anywhere (temporary), ICMP from private network
Kubernetes ports (6443, 10250, 2379-2380, 30000-32767) are intentionally excluded from the Hetzner firewall and added only when deploying Kubernetes.
Host-Level Security
SSH hardening: key-only auth, no root login, limited retries, no TCP forwarding on workers
fail2ban: SSH brute-force protection on all nodes
UFW: host-level firewall enforcing the same rules as the Hetzner firewall
For operational security details (SSH configuration directives, monitoring, security auditing), see Operations: Security.
Kubernetes-Level Security
Cilium CNI: eBPF-based network policies with FQDN egress filtering
Namespace isolation: system-unrestricted (full egress) and apps-restricted (whitelist-only egress)
How DNS64 and NAT64 give IPv6-only nodes and pods transparent access to IPv4-only services.
The cluster is IPv6-only – but many services (GitHub CDN, container registries, package repos) are IPv4-only. NAT64/DNS64 provides transparent IPv4 reachability at the network layer, no application changes needed. This page covers how DNS64/NAT64 works, how it integrates with Kubernetes CoreDNS, and how to configure it.
The Problem: IPv4 Internet from IPv6-Only Nodes
The nodes have no public IPv4 addresses. Most internet services (GitHub, Docker Hub, package repos) have IPv4 addresses. How does an IPv6-only node reach them?
┌──────────────────────────────────────────────────────────────────┐
│ The Problem │
│ │
│ Node (IPv6 only) ──────╳──────► github.com (140.82.121.3) │
│ 2a01:4f8:... │ IPv4 address │
│ No IPv4 route! │
└──────────────────────────────────────────────────────────────────┘
The answer is DNS64 + NAT64, a standard mechanism (RFC 6146/6147) that gives IPv6-only clients transparent access to IPv4 servers.
How DNS64 + NAT64 Works (Node Level)
When a node needs to reach an IPv4-only service, the DNS64 resolver synthesizes a special IPv6 address that embeds the IPv4 address:
Key detail: DNS64 only synthesizes AAAA records for domains that have no native AAAA record. If a domain already has an IPv6 address (like google.com), the DNS64 resolver returns the real AAAA record and no synthesis happens.
DNS Inside the Kubernetes Cluster (Dual-Stack)
With dual-stack networking, every pod gets both an IPv4 address (for internal cluster communication) and an IPv6 address (for external access via DNS64/NAT64). This eliminates the need for hostNetwork workarounds:
CoreDNS: The Bridge Between Cluster and External DNS
CoreDNS runs as a regular pod with dual-stack addresses. It forwards external queries to DNS64 resolvers, which synthesize AAAA records for IPv4-only domains. This allows all pods to reach external services via NAT64:
How Pods Reach External Services (Dual-Stack + NAT64)
With dual-stack, pods have IPv6 addresses and can route to the NAT64 prefix. Cilium’s IPv6 masquerading translates pod source addresses to the node’s public IPv6:
┌──────────────────────────────────────────────────────────────────┐
│ Pod Network Connectivity (Dual-Stack) │
│ │
│ Pod (10.244.0.5 + fd00:10:244::5) │
│ ├── ✅ → 10.96.0.10 (CoreDNS ClusterIP) ── DNS works │
│ ├── ✅ → 10.111.194.145 (nginx ClusterIP) ── service routing │
│ ├── ✅ → 10.0.0.2 (node private IP) ── host reachable │
│ ├── ✅ → 64:ff9b::8c52:7903 (github.com via NAT64) ── works! │
│ └── ✅ → 2a00:1450:... (google.com native IPv6) ── works! │
│ │
│ Flow for IPv4-only destinations (e.g., api.anthropic.com): │
│ 1. Pod queries CoreDNS → forwards to DNS64 resolver │
│ 2. DNS64 synthesizes: api.anthropic.com → 64:ff9b::6812:0000 │
│ 3. Cilium DNS proxy records the FQDN→IP mapping │
│ 4. Pod sends IPv6 to 64:ff9b::6812:0000 │
│ 5. Cilium masquerades src to node's public IPv6 │
│ 6. NAT64 gateway translates to IPv4 → reaches api.anthropic.com │
│ │
│ Cilium FQDN policies enforce egress at every step: │
│ toFQDNs: "api.anthropic.com" → allows 64:ff9b::6812:0000 │
│ All other external traffic is DENIED. │
└──────────────────────────────────────────────────────────────────┘
Full DNS Resolution Example: Pod Resolves an External Name
Here’s the complete flow when a pod in apps-restricted queries api.anthropic.com:
┌──────────────────────────────────────────────────────────────────┐
│ Complete DNS Flow: Pod → CoreDNS → DNS64 → NAT64 │
│ │
│ 1. Pod sends DNS query │
│ src: 10.244.0.5 → dst: 10.96.0.10:53 │
│ "What is api.anthropic.com?" │
│ │ │
│ ▼ │
│ 2. Cilium DNS proxy intercepts the query │
│ Records the FQDN for policy matching │
│ Forwards to CoreDNS │
│ │ │
│ ▼ │
│ 3. CoreDNS receives query │
│ kubernetes plugin: "api.anthropic.com" ≠ cluster.local │
│ forward plugin: forward to DNS64 resolver (2001:67c:2b0::4) │
│ │ │
│ ▼ │
│ 4. DNS64 resolver synthesizes AAAA record │
│ api.anthropic.com has only A records (104.18.x.x) │
│ Synthesizes: AAAA 64:ff9b::6812:0000 │
│ │ │
│ ▼ │
│ 5. Cilium DNS proxy records the mapping │
│ api.anthropic.com → 64:ff9b::6812:0000 │
│ toFQDNs rule "api.anthropic.com" now allows this IP │
│ │ │
│ ▼ │
│ 6. Pod connects to 64:ff9b::6812:0000:443 │
│ Cilium checks egress policy → ALLOWED (FQDN match) │
│ IPv6 masquerade: src becomes node's public IPv6 │
│ NAT64 gateway translates to IPv4 104.18.x.x │
│ ✅ SUCCESS -- pod reaches api.anthropic.com │
└──────────────────────────────────────────────────────────────────┘
Full DNS Resolution Example: Host Resolves a Cluster Service
Here’s the flow when cloudflared (system service on the host) needs to reach a Kubernetes service:
┌──────────────────────────────────────────────────────────────────┐
│ Complete DNS Flow: cloudflared → CoreDNS → Kubernetes API │
│ │
│ 1. cloudflared queries the host's DNS │
│ /etc/resolv.conf: nameserver 10.96.0.10 (CoreDNS ClusterIP) │
│ "What is nginx.apps-restricted.svc.cluster.local?" │
│ │ │
│ ▼ │
│ 2. CoreDNS receives query │
│ kubernetes plugin: matches *.svc.cluster.local │
│ Queries Kubernetes API for Service "nginx" in │
│ namespace "apps-restricted" │
│ │ │
│ ▼ │
│ 3. CoreDNS returns ClusterIP │
│ nginx.apps-restricted.svc.cluster.local → 10.111.194.145 │
│ │ │
│ ▼ │
│ 4. cloudflared connects to 10.111.194.145:80 │
│ Cilium routes the ClusterIP to the nginx pod │
│ ✅ SUCCESS -- host can reach ClusterIP via Cilium │
└──────────────────────────────────────────────────────────────────┘
Summary: Who Resolves What
Consumer
Resolver
Cluster names
External names
External connectivity
Host processes (cloudflared, apt)
CoreDNS (ClusterIP) + Hetzner DNS
Yes (via CoreDNS)
Yes (via Hetzner DNS)
Full (IPv6 + NAT64)
All pods (CoreDNS, CSI, OpenClaw, etc.)
CoreDNS (10.96.0.10 ClusterIP)
Yes
Yes (AAAA synthesized via DNS64)
Full (IPv6 + NAT64 via masquerade)
Node itself (DNS64 configured)
DNS64 resolvers (2001:67c:2b0::4)
No
Yes (AAAA synthesized)
Full (IPv6 + NAT64)
Configuration
NAT64/DNS64 is enabled by default (enable_nat64 = true). Cloud-init configures it on new nodes automatically. Default resolvers are from nat64.net (Nuremberg, Helsinki, Amsterdam) – close to Hetzner’s fsn1 datacenter.
For existing nodes
Run the Ansible playbook:
cd ansible
ansible-playbook playbooks/configure-nat64.yml
# DNS64 synthesis (should show AAAA record with 64:ff9b:: prefix)resolvectl query github.com
# End-to-end connectivitycurl -6 https://github.com
Technical Details
What cloud-init configures
DNS64 resolvers in /etc/systemd/resolved.conf.d/dns64.conf
NAT64 route: 64:ff9b::/96 via the default IPv6 gateway
networkd-dispatcher script to persist the route across reboots
What the Ansible playbook configures
The same as cloud-init, plus:
Removes old Hetzner DNS UFW rules on worker nodes
Adds DNS64 resolver allow rules in UFW (workers only)
Adds NAT64 prefix UFW rule (workers only)
Verifies DNS64 resolution and NAT64 connectivity
Worker node specifics
Worker nodes have restricted outbound access. The NAT64 configuration adds:
UFW rules allowing DNS to DNS64 resolvers (instead of Hetzner DNS)
UFW rule allowing traffic to the 64:ff9b::/96 prefix
4 - Guide
Deploy and operate the KubeClaw infrastructure step by step.
Follow these guides to provision the infrastructure, configure the nodes, and
deploy OpenClaw on Kubernetes.
4.1 - Dev Container
Set up the aibox Dev Container that carries every tool this project needs.
The project uses an aibox-generated Dev Container (Debian Trixie) as a
self-contained, reproducible environment. Nothing needs to be set up on the
host machine beyond Docker and an IDE with Dev Container support.
Ansible – server management (runs inside the container, no host install needed)
cloudflared – SSH ProxyCommand via Cloudflare Tunnel
ssh-agent – start inside the container to use passphrase-protected keys with Ansible
AI CLI tools – Codex and the aibox toolchain
Hugo + Docsy – documentation site (./scripts/serve-docs.sh at port 1313)
Persistence – .aibox-home/.ssh/ is bind-mounted read-write by this
project, so setup-ssh.sh output, SSH config, and exported keys survive
container rebuilds
Step 1: Prepare Persistent Directories
Create the persistent directories before opening the Dev Container:
git clone <repo-url>
cd kubeclaw
# Create the persistent SSH directory mounted at /home/aibox/.ssh.mkdir -p .aibox-home/.ssh
chmod 700 .aibox-home/.ssh
The .aibox-home/ directory is gitignored. Its .ssh/ subdirectory holds
your private keys, SSH configuration, and known_hosts without risking a
commit of secrets.
Step 2: Open in Dev Container
1. Install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension
2. Open the project folder in VS Code
3. Click **"Reopen in Container"** when prompted, or press ++cmd+shift+p++ and select **Dev Containers: Reopen in Container**
The generated setup uses Docker Compose. Use your editor’s Dev Container
command rather than docker build directly so the generated Compose mounts,
including the persistent aibox home directory, are applied.
Step 3: Start SSH Agent
source ./scripts/ssh-agent-setup.sh
Note
No SSH keys exist yet at this point -- they are created later during [Infrastructure provisioning](/kubeclaw/docs/guide/infrastructure/). This step starts the ssh-agent and fixes SSH directory permissions, preparing the environment for later steps.
Important
The `source` command is required -- running the script without `source` starts the agent in a subprocess that terminates immediately.
SSH persistence
Docker bind mounts from macOS may not preserve Unix permissions. The
`ssh-agent-setup.sh` script automatically fixes SSH directory permissions on
every run.
The `.aibox-home/.ssh/` directory stores SSH private keys, SSH config, and
`known_hosts` -- all exported or generated by `setup-ssh.sh`. Everything
survives container rebuilds.
Step 4: Verify Setup
Confirm that the essential tools are available:
tofu --version
ansible --version
hugo version
npm --version
Preview the documentation:
./scripts/serve-docs.sh
# Open http://localhost:1313 in your browser
4.2 - Infrastructure (OpenTofu)
Configure terraform.tfvars, run tofu apply, and set up SSH access to the new nodes.
KubeClaw uses OpenTofu (Terraform-compatible) to provision all infrastructure on Hetzner Cloud. This page walks through the provisioning workflow step by step. See Variables Reference for all configurable options.
Step 1: Configure terraform.tfvars
cp terraform.tfvars.example terraform.tfvars
Edit terraform.tfvars with your settings:
hcloud_token="your-hcloud-api-token"root_password="a-strong-root-password"cluster_name="k8s-cluster"# Optional: auto-configure Cloudflare Tunnel on the master control node
# cloudflare_tunnel_token = "eyJ..."
SSH key options
No configuration needed. OpenTofu generates ED25519 keys and stores them in the state file.
| Advantage | Disadvantage |
|-----------|--------------|
| No manual key creation | State file contains private keys |
| Works out of the box | Keys lost if state is lost |
Create your own keys and reference them in `terraform.tfvars`:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/k8s-cluster_control-node_key -C "control-node"
ssh-keygen -t ed25519 -f ~/.ssh/k8s-cluster_worker-node_key -C "worker-node"
```
```hcl
control_node_public_key = "ssh-ed25519 AAAA... control-node"
worker_node_public_key = "ssh-ed25519 AAAA... worker-node"
```
| Advantage | Disadvantage |
|-----------|--------------|
| Full control over key storage | Manual key management |
| State has no private keys | Must create keys before provisioning |
| Easy password manager integration | |
Admin node at 10.0.0.254 (if enable_admin_node = true)
Master control node at 10.0.0.2 (with cloudflared if tunnel token is set)
Replica control nodes and workers (if configured)
Step 3: Set Up SSH Access
./scripts/setup-ssh.sh
This exports SSH private keys from OpenTofu state and generates ~/.ssh/config entries. Test the connection:
ssh control-node
Tip
If you used `cloudflare_tunnel_token`, SSH routes through Cloudflare Tunnel automatically. Otherwise, the admin node serves as a jump host during initial setup.
Create the tunnel, route SSH through it, and protect it with a Cloudflare Access policy.
This guide walks through creating and configuring a Cloudflare Tunnel that provides secure SSH access to your KubeClaw cluster. The tunnel replaces the temporary admin node as the permanent access path – no open ports, no public SSH, outbound-only connectivity.
Why Cloudflare Tunnel?
KubeClaw nodes have no public IPv4 and no inbound SSH ports. Access works through one of two paths:
Path
When to use
How it works
Admin node (temporary)
Initial setup, before tunnel is ready
Jump host with public IPv6; SSH via ProxyJump
Cloudflare Tunnel (permanent)
After tunnel is configured
cloudflared on the master node connects outbound to Cloudflare’s edge; SSH proxied via ProxyCommand cloudflared access ssh on your local machine
After the tunnel is working, you disable the admin node (enable_admin_node = false in terraform.tfvars) and all SSH flows through Cloudflare.
cloudflared on both sides
`cloudflared` runs in two places, because there is no direct SSH path to the cluster. The hostname `console.yourdomain.org` points to Cloudflare's edge servers, which only speak HTTPS -- a plain `ssh console.yourdomain.org` on port 22 would be refused. Instead, the SSH byte stream is wrapped inside HTTPS and carried through the tunnel:
1. **Your machine / Dev Container** (client side): The SSH config uses `ProxyCommand cloudflared access ssh --hostname %h`. Instead of opening a TCP connection, SSH pipes its traffic through this subprocess. `cloudflared` connects to Cloudflare's edge over HTTPS (port 443), handles Cloudflare Access authentication (browser-based, if configured), and forwards the SSH bytes.
2. **Cloudflare edge**: Validates the Access JWT, looks up which tunnel serves the hostname, and forwards the traffic to the matching tunnel connector.
3. **Master control node** (server side): The server-side `cloudflared` maintains a persistent outbound connection to Cloudflare's edge. It receives the forwarded traffic and proxies it to `localhost:22`, where sshd handles normal key-based authentication.
The full chain: `ssh` ↔ `cloudflared (local)` ↔ `Cloudflare edge (HTTPS)` ↔ `cloudflared (server)` ↔ `sshd`.
The Dev Container includes cloudflared. On macOS, install it with `brew install cloudflared`.
Prerequisites
A Cloudflare account (free tier is sufficient)
A domain added to Cloudflare (Cloudflare must be the DNS provider)
Infrastructure provisioned with tofu apply (the master control node must be running)
Step 1: Open Zero Trust Dashboard
Go to https://one.dash.cloudflare.com and log in. This opens the Cloudflare Zero Trust dashboard (formerly Cloudflare for Teams).
Enter a tunnel name, e.g. kubeclaw or hetzner-cluster
┌──────────────────────────────────────────────────────────────────┐
│ Name your tunnel │
│ │
│ Tunnel name: ┌──────────────────────────────┐ │
│ │ kubeclaw │ │
│ └──────────────────────────────┘ │
│ │
│ [ Save tunnel ] │
└──────────────────────────────────────────────────────────────────┘
Click Save tunnel
Step 3: Copy the Tunnel Token
After saving, Cloudflare shows the connector installation instructions. The page displays install commands for various platforms. You need the token value from the install command.
┌──────────────────────────────────────────────────────────────────┐
│ Install and run a connector │
│ │
│ Choose your environment: │
│ [ Debian ] [ Docker ] [ macOS ] [ Windows ] │
│ │
│ Install and run a connector: │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ sudo cloudflared service install eyJhIjoiY2Y...long-token │ │
│ └────────────────────────────────────────────────────────────┘ │
│ 📋 │
│ │
│ The token is: eyJhIjoiY2Y... │
│ │
│ [ Next ] │
└──────────────────────────────────────────────────────────────────┘
Copy the token (the eyJ... string). You will need it in two places:
terraform.tfvars — so cloud-init auto-installs the tunnel on the master node
Manual install — if the infrastructure is already running
Save the token securely
The tunnel token is a long-lived credential. Store it in your password manager alongside the Hetzner API token.
Click Next to proceed to the hostname configuration.
Step 4: Add a Public Hostname for SSH
This step maps a subdomain to the SSH service on your master control node.
You should now be on the Route tunnel screen, or navigate to your tunnel’s Public Hostname tab
The tunnel connector runs on the master node, so SSH is on localhost
Click Save hostname
The resulting hostname (e.g. console.yourdomain.org) is what you’ll use as cloudflare_tunnel_domain in terraform.tfvars and as the SSH Host in your SSH config.
Step 5: Create an Access Application (Optional but Recommended)
Cloudflare Access adds browser-based authentication before the SSH connection is established. Without it, anyone who knows your tunnel hostname can attempt SSH connections (still protected by SSH keys, but Access adds a second layer).
On first SSH connection, `cloudflared access ssh` opens a browser window. You authenticate via Cloudflare Access (email OTP, or your configured identity provider). After authentication, SSH proceeds normally with key-based auth. The browser session lasts for the configured session duration.
After completing the Cloudflare setup, continue with Infrastructure (OpenTofu) to configure the tunnel token and domain in terraform.tfvars, provision the cluster, and verify connectivity.
How SSH Routing Works
After the tunnel is active and the admin node is disabled:
Generate the inventory and run the update, hardening, NAT64, and Kubernetes-prerequisite playbooks.
Ansible handles ongoing server management: system updates, security hardening, NAT64 configuration, and Kubernetes prerequisites. All playbooks run from the Dev Container.
Step 1: Set Up Ansible
1.1 Generate inventory
./scripts/generate-ansible-inventory.sh
The inventory (ansible/inventory.ini) is auto-generated. It defines three groups:
control_nodes – all control plane nodes
worker_nodes – all worker nodes
k8s_cluster – union of control and worker nodes
Warning
Never hand-edit `inventory.ini`. Regenerate after infrastructure changes with `./scripts/generate-ansible-inventory.sh`.
1.2 Load SSH keys
source ./scripts/ssh-agent-setup.sh
Important
The ssh-agent must be running with the cluster keys loaded before Ansible can connect. Run `source ./scripts/ssh-agent-setup.sh` in every new terminal session.
1.3 Test connectivity
cd ansible
ansible all -m ping
Step 2: Apply System Updates
ansible-playbook playbooks/update-system.yml
Target specific node groups or enable automatic reboots:
# Only control nodesansible-playbook playbooks/update-system.yml --limit control_nodes
# With reboot if kernel was updatedansible-playbook playbooks/update-system.yml -e "reboot_after_update=true"
Step 3: Apply Security Hardening
ansible-playbook playbooks/security-hardening.yml
This enables:
Unattended upgrades (automatic security updates)
fail2ban monitoring
Kernel security parameters (sysctl hardening)
Secure permissions on sensitive files
Core dump disabling
Step 4: Configure NAT64/DNS64
For existing nodes that weren’t configured via cloud-init:
This installs the selected container runtime, kubeadm, kubelet, and kubectl on
all nodes. The runtime defaults to containerd; pass
-e "container_runtime=cri-o" to use CRI-O instead, matching the
container_runtime variable used by cloud-init.
Bootstrap a dual-stack kubeadm cluster with Cilium CNI and the Hetzner CSI driver.
After provisioning the infrastructure with OpenTofu and configuring SSH access, deploy a standard Kubernetes cluster using kubeadm with Cilium as the CNI.
Why kubeadm + Cilium?
kubeadm: The official Kubernetes bootstrapper. Produces a standard, upstream cluster – exactly what the CKA exam expects. Full control over every component (etcd, kube-apiserver, kube-scheduler, kube-controller-manager).
Cilium: eBPF-based CNI providing advanced network policies with FQDN-based egress filtering – critical for restricting outbound traffic per namespace.
Step 1: Initialize the Control Plane (Dual-Stack)
SSH into the master control node. Prerequisites (containerd, kubeadm, kubelet, kubectl) are already installed via cloud-init when enable_k8s_prereqs = true (default).
First, determine the master’s private IP (default: 10.0.0.2, depends on subnet_ip_range):
# From the Dev Container:tofu output -raw master_control_node_private_ip
The commands below use $MASTER_IP. Set it on the master node before proceeding:
MASTER_IP=10.0.0.2
Do not use `hostname -I`
`hostname -I` may return Hetzner's CGNAT address (`100.64.x.x`) as the first IP instead of the private network IP. Always set `MASTER_IP` explicitly to the private network address from your `subnet_ip_range` (default: `10.0.0.2`).
1.1 Verify prerequisites
IPv6 forwarding
sudo sysctl net.ipv6.conf.all.forwarding
Expected output: net.ipv6.conf.all.forwarding = 1
If not set, enable it:
echo"net.ipv6.conf.all.forwarding = 1"| sudo tee -a /etc/sysctl.d/k8s.conf
sudo sysctl --system
Each node needs both an IPv4 and IPv6 address for dual-stack. The IPv4 is the private network IP (10.0.0.2). For IPv6, use the node’s public IPv6 address:
NODE_IPV6=$(ip -6 addr show scope global | grep -oP '(?<=inet6\s)[\da-f:]+'| head -1)echo$NODE_IPV6
This should print a public IPv6 address like 2a01:4f8:xxxx:xxxx::1. Save this – you’ll need it for kubelet configuration.
1.3 Initialize with kubeadm (dual-stack)
Verify MASTER_IP before proceeding
Run `echo $MASTER_IP` -- it **must** print `10.0.0.2` (or your custom subnet IP). If it's empty, go back and set it. Running `kubeadm init` with an empty or wrong `--apiserver-advertise-address` will bind the API server to the wrong interface and generate TLS certificates with incorrect SANs. Fixing this requires a full `kubeadm reset` and re-init.
--apiserver-advertise-address=$MASTER_IP – Bind the API server to the private network IP (single address, not dual-stack)
--pod-network-cidr=10.244.0.0/16,fd00:10:244::/48 – Dual-stack pod CIDRs: IPv4 for internal cluster communication + IPv6 for external connectivity via DNS64/NAT64
--service-cidr=10.96.0.0/12,fd00:10:96::/108 – Dual-stack service CIDRs: existing IPv4 services continue to work; new services can opt into dual-stack
--skip-phases=addon/kube-proxy – Cilium replaces kube-proxy with eBPF datapath
CKA note
The CKA exam typically uses kube-proxy. This cluster skips it because Cilium provides a more efficient replacement. On the exam, omit `--skip-phases=addon/kube-proxy`.
Why dual-stack?
The nodes are IPv6-only with NAT64/DNS64 for IPv4 reachability. By giving pods IPv6 addresses alongside IPv4, they can reach external services via DNS64/NAT64 natively -- no `hostNetwork` workarounds needed. This means Cilium's FQDN-based egress policies apply to **all** pods, including CoreDNS, the CSI controller, and application workloads like OpenClaw.
CIDRs cannot be changed after init
kubeadm does not support modifying pod or service CIDRs after initialization. If you need different ranges, you must `kubeadm reset` and re-init.
Expected output (abbreviated, IPs and hashes will differ):
[init] Using Kubernetes version: v1.32.x
[preflight] Running pre-flight checks
...
[addons] Applied essential addon: CoreDNS
Your Kubernetes control-plane has initialized successfully!
To start using your cluster, you need to run the following as a regular user:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
Then you can join any number of worker nodes by running the following on each as root:
kubeadm join <MASTER_IP>:6443 --token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH>
Expected warnings
**"remote version is much newer"** -- This is normal when pinning `kubernetes_version` (e.g., `1.32`). kubeadm detects a newer stable release exists but correctly falls back to the pinned version. No action needed.
**"sandbox image is inconsistent"** -- The containerd default config ships `pause:3.8` but kubeadm 1.32 expects `pause:3.10`. The cloud-init and Ansible prerequisites already fix this. If you see this warning, update the sandbox image manually: `sudo sed -i 's|registry.k8s.io/pause:3.8|registry.k8s.io/pause:3.10|' /etc/containerd/config.toml && sudo systemctl restart containerd`
What kubeadm init does behind the scenes:
Generates PKI certificates (CA, API server, kubelet, etc.) in /etc/kubernetes/pki/
Writes static pod manifests for etcd, kube-apiserver, kube-controller-manager, kube-scheduler in /etc/kubernetes/manifests/
Bootstraps etcd and starts the API server
Configures RBAC and creates bootstrap tokens
Generates admin.conf kubeconfig for cluster administration
Set the kubelet’s --node-ip to both the IPv4 private network address and the node’s IPv6 address. This ensures endpoints are registered with both addresses:
sudo sed -i "s/KUBELET_KUBEADM_ARGS=\"/KUBELET_KUBEADM_ARGS=\"--node-ip=$MASTER_IP,$NODE_IPV6 /" /var/lib/kubelet/kubeadm-flags.env
sudo systemctl restart kubelet
Verify:
kubectl get nodes -o wide
The INTERNAL-IP column should show the IPv4 address. Check that both addresses are registered:
kubectl get nodes -o jsonpath='{.items[0].status.addresses}'| python3 -m json.tool
You should see both InternalIP entries (IPv4 and IPv6).
1.6 Save join command
# Print the join command (token valid for 24h)kubeadm token create --print-join-command
Save this output – you’ll need it for worker nodes.
1.7 Verify
Node status
kubectl get nodes
The node shows NotReady until the CNI (Cilium) is installed in Step 3:
NAME STATUS ROLES AGE VERSION
<cluster>-control-01 NotReady control-plane XXm v1.32.x
System pods
kubectl get pods -n kube-system
Core control plane pods should be Running. CoreDNS pods remain Pending until the CNI is installed:
Step 2: Join Worker Nodes and Further Control Nodes
Worker Nodes
SSH into each worker node and run the join command from Step 1.6. Before joining, configure the kubelet for dual-stack on the worker:
# On the worker node, determine its IPsWORKER_IPV4=10.0.0.X # Replace with the worker's private IPWORKER_IPV6=$(ip -6 addr show scope global | grep -oP '(?<=inet6\s)[\da-f:]+'| head -1)
After joining, set the worker’s dual-stack node IP:
sudo sed -i "s/KUBELET_KUBEADM_ARGS=\"/KUBELET_KUBEADM_ARGS=\"--node-ip=$WORKER_IPV4,$WORKER_IPV6 /" /var/lib/kubelet/kubeadm-flags.env
sudo systemctl restart kubelet
CKA explainer -- TLS bootstrap
The worker uses the bootstrap token to authenticate with the API server, then requests a kubelet client certificate. The API server validates the token, signs the certificate, and the kubelet starts using it for all subsequent communication. This is the TLS bootstrap process.
The install script downloads from GitHub via NAT64. This generally works but connections can be flaky. If the download times out, simply retry the command.
3.2 Install Cilium via Helm
Add the Cilium Helm repository and install. Make sure $MASTER_IP is still set (echo $MASTER_IP) – if not, re-export it (see Step 1):
--version 1.16.5 – Pin the Cilium version for reproducibility
--set kubeProxyReplacement=true – Replace kube-proxy with Cilium’s eBPF datapath (matches --skip-phases=addon/kube-proxy from Step 1)
--set k8sServiceHost / k8sServicePort – Required when kube-proxy is skipped, so Cilium knows how to reach the API server
--set ipam.mode=kubernetes – Use the Kubernetes host-scope IPAM. Without this, Cilium defaults to its own cluster-pool allocator with CIDRs 10.0.0.0/8 and fd00::/104, ignoring kubeadm’s --pod-network-cidr. This causes pod IPs to overlap with the Hetzner private network (10.0.0.0/24)
--set ipv4.enabled=true – Enable IPv4 pod networking (cluster-internal communication)
--set ipv6.enabled=true – Enable IPv6 pod networking (external access via DNS64/NAT64)
--set enableIPv6Masquerade=true – Masquerade pod IPv6 traffic to the node’s public IPv6 when leaving the cluster. This is what allows pods to reach external services via NAT64
--set operator.replicas=1 – Cilium defaults to 2 operator replicas, but since the operator uses a host port, only one can run per node. Set to 1 for single-node clusters; increase when adding worker nodes
--set cni.binPath=/usr/lib/cni – Debian’s containerd package looks for CNI binaries in /usr/lib/cni instead of the default /opt/cni/bin/. Without this, kubelet reports cni plugin not initialized. If using CRI-O, omit this flag (CRI-O uses the default /opt/cni/bin)
Restart containerd after Cilium install
After Cilium deploys the CNI plugin, containerd may have cached the "not initialized" state. Restart it to pick up the new CNI:
```bash
sudo systemctl restart containerd
```
Wait a few seconds, then verify the node becomes `Ready` with `kubectl get nodes`.
3.3 Wait and verify
# Wait for the Cilium DaemonSet to roll outkubectl -n kube-system rollout status daemonset/cilium --timeout=120s
# Verify Cilium pods are runningkubectl get pods -n kube-system -l k8s-app=cilium
After containerd restarts and Cilium is ready, all nodes should show Ready:
kubectl get nodes
Single-node cluster: removing the control-plane taint
By default, kubeadm applies a `NoSchedule` taint to control-plane nodes. This prevents regular workloads from running on nodes dedicated to the Kubernetes control plane (API server, etcd, scheduler), reserving their resources for cluster management. On a multi-node cluster this is the correct behavior.
On a single control-plane node without workers, this taint prevents **all** pod scheduling (including CoreDNS), so it must be removed:
```bash
kubectl taint nodes --all node-role.kubernetes.io/control-plane:NoSchedule-
```
If you later add worker nodes and want to restore the taint to keep workloads off the control plane:
```bash
kubectl taint nodes node-role.kubernetes.io/control-plane:NoSchedule
```
3.4 Verify dual-stack pod connectivity
Once CoreDNS is running, verify that pods have both IPv4 and IPv6 addresses:
kubectl get pods -n kube-system -l k8s-app=kube-dns -o wide
Each pod should have two IPs – one from 10.244.0.0/16 (IPv4) and one from fd00:10:244::/48 (IPv6).
Step 4: Configure CoreDNS for DNS64
With dual-stack pods, CoreDNS has IPv6 connectivity and can reach external DNS servers directly – no hostNetwork needed. However, CoreDNS must forward to DNS64 resolvers (not regular DNS) so that IPv4-only domains get synthesized AAAA records that pods can route to via NAT64.
4.1 Update CoreDNS ConfigMap
kubectl -n kube-system edit configmap coredns
Replace the forward line. Change:
forward . /etc/resolv.conf
To:
forward . 2001:67c:2b0::4 2001:67c:2b0::6
These are public DNS64 resolvers from nat64.net (Nuremberg and Helsinki – close to Hetzner’s datacenters). They synthesize AAAA records with the 64:ff9b::/96 prefix for IPv4-only domains.
Why DNS64 resolvers?
When a pod queries `api.anthropic.com`, CoreDNS forwards to the DNS64 resolver. If `api.anthropic.com` only has an A record (IPv4), the DNS64 resolver synthesizes an AAAA record like `64:ff9b::6812:0000`. The pod then sends IPv6 traffic to this address, Cilium masquerades it to the node's public IPv6, and the NAT64 gateway translates it to IPv4. This is how pods reach IPv4-only services without `hostNetwork`.
Create a long-running test pod (the --rm -it pattern tends to hang on IPv6-only clusters):
kubectl run test --image=alpine --restart=Never -- sleep 3600
Test DNS and connectivity:
# Test internal DNS (cluster service name)kubectl exectest -- nslookup kubernetes.default.svc.cluster.local
# Test external DNS (should return a synthesized AAAA from the DNS64 resolver)kubectl exectest -- nslookup github.com
# Install curl and test end-to-end NAT64 connectivitykubectl exectest -- apk add --no-cache curl
kubectl exectest -- curl -6 -s --max-time 10 -o /dev/null -w "%{http_code}\n" https://github.com
Expected results: nslookup github.com returns both a synthesized AAAA (e.g. 2001:67c:2b0:db32:...) and a real A record. The curl -6 command forces IPv6 and should return 200 (or 301 for domains that redirect, like google.com).
BusyBox wget prefers IPv4
Alpine's BusyBox wget tries IPv4 first and has no `-6` flag. Since pods have no IPv4 internet route, `wget https://github.com` will fail with "Network unreachable". Use `curl -6` for testing, or note that glibc-based images (like `node:lts`) prefer IPv6 by default per RFC 6724.
Clean up:
kubectl delete pod test
Debugging DNS
If DNS resolution fails, check the CoreDNS logs:
```bash
kubectl logs -n kube-system -l k8s-app=kube-dns -f
```
Verify that CoreDNS pods have IPv6 connectivity to the DNS64 resolvers:
```bash
kubectl run test --image=alpine --restart=Never -- sleep 300
kubectl exec test -- ping6 -c 3 -W 3 2001:67c:2b0::4
kubectl delete pod test
```
Step 5: Install Hetzner CSI Driver
The Hetzner CSI driver enables persistent storage via Hetzner Block Volumes. With dual-stack networking, the CSI controller can reach api.hetzner.cloud through its pod IPv6 address and NAT64 – no hostNetwork patch needed.
5.1 Create a dedicated API token
In the Hetzner Cloud Console: Security > API Tokens > Generate API Token (Read & Write). Name it k8s-csi.
5.2 Deploy the CSI driver
# Create secret with API tokenkubectl create secret generic hcloud \
--namespace kube-system \
--from-literal=token=<YOUR_HETZNER_CSI_API_TOKEN>
# Deploy CSI driverkubectl apply -f https://raw.githubusercontent.com/hetznercloud/csi-driver/main/deploy/kubernetes/hcloud-csi.yml
# Set hcloud-volumes as default storage classkubectl patch storageclass hcloud-volumes \
-p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
5.3 Verify
kubectl get storageclass
kubectl get pods -n kube-system | grep hcloud
# hcloud-csi-controller should show 5/5 Running# hcloud-csi-node should show 3/3 Running
If the CSI controller fails to start with connection errors to api.hetzner.cloud, verify that:
CoreDNS is forwarding to DNS64 resolvers (Step 4)
The pod has an IPv6 address (kubectl get pods -n kube-system -o wide | grep hcloud-csi-controller)
The NAT64 route exists on the node (ip -6 route | grep 64:ff9b)
Step 6: Namespace Isolation and Network Policies
Use namespaces with Cilium network policies to isolate workloads and control egress traffic. With dual-stack networking, Cilium’s FQDN-based egress rules work for all pods – the DNS proxy intercepts DNS64-synthesized AAAA responses and allows traffic to those addresses.
6.3 Whitelist specific egress with Cilium (optional)
This step is a reference example — apply it when deploying applications that need specific egress rules (see the OpenClaw guide for a real-world example).
Cilium supports FQDN-based egress rules, allowing fine-grained control over which external services an application can reach. With dual-stack, the DNS proxy intercepts DNS64-synthesized AAAA records and maps them to the FQDN, so toFQDNs rules work transparently with NAT64:
# example-app-egress.yamlapiVersion:cilium.io/v2kind:CiliumNetworkPolicymetadata:name:example-app-egressnamespace:apps-restrictedspec:endpointSelector:matchLabels:app:my-appegress:# DNS resolution (required for FQDN rules)- toEndpoints:- matchLabels:io.kubernetes.pod.namespace:kube-systemk8s-app:kube-dnstoPorts:- ports:- port:"53"protocol:UDP- port:"53"protocol:TCP# Allow internal cluster communication- toEntities:- cluster# Allow specific external API (example)- toFQDNs:- matchName:"api.example.com"toPorts:- ports:- port:"443"protocol:TCP
How FQDN rules work with DNS64/NAT64
When a pod queries `api.example.com`, Cilium's DNS proxy intercepts the response. If the DNS64 resolver returns a synthesized AAAA record (`64:ff9b::xxxx`), the proxy records the mapping `api.example.com → 64:ff9b::xxxx`. The `toFQDNs` rule then allows traffic to that synthesized address. From Cilium's perspective, a DNS64-synthesized AAAA record is just a regular AAAA record.
6.4 Unrestricted egress for system namespace
# system-unrestricted-egress.yamlapiVersion:cilium.io/v2kind:CiliumNetworkPolicymetadata:name:allow-all-egressnamespace:system-unrestrictedspec:endpointSelector:{}egress:- toEntities:- all
kubectl apply -f system-unrestricted-egress.yaml
6.5 Verify network policies
Use wget (included in Alpine by default) to verify that the default-deny policy blocks all egress, including DNS resolution:
# Start a test pod in the restricted namespacekubectl run test --namespace apps-restricted --rm -it --image=alpine -- sh
# Inside the pod -- both should FAIL with DNS or connection errors:wget -qO- https://google.com
ping -c1 8.8.8.8
# Exit test podexit
Why not `curl`?
You might think to `apk add curl` first, but that itself requires DNS resolution and network access to reach the Alpine package mirror -- which is exactly what the default-deny egress policy blocks. The fact that `apk` fails is already proof that the policy works. Alpine ships with `wget`, so no package installation is needed.
Then verify that the unrestricted namespace allows full egress:
kubectl run test --namespace system-unrestricted --image=alpine --restart=Never -- sleep 3600# Install curl and test (BusyBox wget lacks -6 and prefers IPv4 which is unreachable)kubectl exec --namespace system-unrestricted test -- apk add --no-cache curl
kubectl exec --namespace system-unrestricted test -- curl -6 -s --max-time 10 -o /dev/null -w "%{http_code}\n" https://google.com
# Clean upkubectl delete pod --namespace system-unrestricted test
The curl -6 command should return 301 (Google redirects to www.google.com), confirming full IPv6 egress works.
Step 7: Expose Services via Cloudflare Tunnel
The Cloudflare Tunnel (installed as a system service on the master control node via cloud-init) can route external traffic to Kubernetes services. This step deploys a test service in the restricted namespace to verify the full chain: Internet → Cloudflare → Tunnel → Kubernetes Service → Pod – with network policies enforced.
7.1 Deploy a test service
Apply the following manifest. It creates an Nginx deployment, a ClusterIP service, and a CiliumNetworkPolicy in the apps-restricted namespace. The network policy only allows DNS egress (which Nginx doesn’t strictly need, but demonstrates the pattern every real application requires):
# nginx-test.yamlapiVersion:apps/v1kind:Deploymentmetadata:name:nginx-testnamespace:apps-restrictedspec:replicas:1selector:matchLabels:app:nginx-testtemplate:metadata:labels:app:nginx-testspec:containers:- name:nginximage:nginx:alpineports:- containerPort:80resources:requests:memory:"32Mi"cpu:"10m"limits:memory:"64Mi"cpu:"100m"---apiVersion:v1kind:Servicemetadata:name:nginx-testnamespace:apps-restrictedspec:selector:app:nginx-testports:- port:80targetPort:80---apiVersion:cilium.io/v2kind:CiliumNetworkPolicymetadata:name:nginx-test-egressnamespace:apps-restrictedspec:endpointSelector:matchLabels:app:nginx-testegress:# DNS resolution (required for most real applications)- toEndpoints:- matchLabels:io.kubernetes.pod.namespace:kube-systemk8s-app:kube-dnstoPorts:- ports:- port:"53"protocol:UDP- port:"53"protocol:TCP
kubectl apply -f nginx-test.yaml
Verify it’s running:
kubectl get pods -n apps-restricted -l app=nginx-test
kubectl get svc nginx-test -n apps-restricted
7.2 Enable cluster DNS on the host
The cloudflared system service runs on the host, not inside the cluster. By default, it cannot resolve Kubernetes service names like nginx-test.apps-restricted.svc.cluster.local because the host uses Hetzner’s DNS servers, not CoreDNS.
Add CoreDNS as a nameserver on the host. CoreDNS listens on its pod IP (a ClusterIP or node-local address routable via Cilium):
# Find the CoreDNS ClusterIPCOREDNS_IP=$(kubectl get svc kube-dns -n kube-system -o jsonpath='{.spec.clusterIP}')echo"CoreDNS ClusterIP: $COREDNS_IP"sudo sed -i "1s/^/nameserver $COREDNS_IP\n/" /etc/resolv.conf
resolv.conf is managed by resolvconf
The `/etc/resolv.conf` file is auto-generated and may be overwritten on reboot or network changes. To make this permanent, add the nameserver to `/etc/resolvconf/resolv.conf.d/head`:
```bash
echo "nameserver $COREDNS_IP" | sudo tee /etc/resolvconf/resolv.conf.d/head
sudo resolvconf -u
```
Verify the host can resolve cluster service names:
In the Cloudflare dashboard under **yourdomain.com** > **SSL/TLS** > **Overview**, set the encryption mode to **Full** (not **Full (strict)**). The tunnel terminates TLS at Cloudflare and connects to the origin (Nginx) over plain HTTP.
7.4 Test access
From your local machine (or anywhere on the internet):
curl https://test.yourdomain.com
You should see the Nginx welcome page. This confirms the full chain works:
Internet → Cloudflare (TLS termination)
→ Tunnel → cloudflared (system service on host)
→ CoreDNS resolves service name → ClusterIP
→ Nginx pod (apps-restricted namespace, egress restricted by Cilium)
Ingress is allowed by default
The default-deny policy from Step 6.2 only restricts **egress**. Ingress to pods in `apps-restricted` is allowed, so cloudflared can reach Nginx without an additional ingress rule. For production workloads, consider adding explicit ingress policies as well.
7.5 Clean up
Remove the test resources and the Cloudflare public hostname:
kubectl delete -f nginx-test.yaml
Then in the Cloudflare dashboard: Zero Trust > Networks > Tunnels > your tunnel > Public Hostnames > delete the test.yourdomain.com entry.
Authentication
At this point, the tunnel exposes services without authentication. Adding Cloudflare Access policies (Zero Trust > Access > Applications) to control who can reach your services is covered in the [OpenClaw deployment guide](/kubeclaw/docs/guide/openclaw/).
7.6 Optional: Run cloudflared as a Kubernetes workload
The system-level cloudflared installed via cloud-init is sufficient for most setups. If you prefer to manage the tunnel as a Kubernetes deployment (for HA with multiple replicas, resource limits, and Kubernetes-native lifecycle management), you can migrate it:
Deploy OpenClaw into an egress-restricted namespace with Cilium FQDN policies.
A step-by-step guide to deploying OpenClaw as an isolated AI assistant on the KubeClaw cluster, accessible via Telegram, WhatsApp, or Signal, with a web-based Control UI exposed through Cloudflare Tunnel.
<YOUR_PHONE_E164> — your phone number in E.164 format (e.g. +15551234567)
<BOT_PHONE_E164> — dedicated phone number for the Signal bot
Model configuration
The config above uses Sonnet as the primary model with Haiku as a fallback. OpenClaw automatically fails over when the primary model hits rate limits, auth errors, or timeouts.
**Customizing models:**
- **`model.primary`** — the default model for all conversations
- **`model.fallbacks`** — ordered list of backup models, tried in sequence on failure
- **`models`** — allowlist with aliases that appear in the Control UI model selector. You can switch models from the UI during a conversation
- **`models.providers`** — provider credentials. Supports Anthropic, OpenAI, Google Gemini, OpenRouter, and local models (Ollama). Add multiple providers to mix models:
```json5
"models": {
"providers": {
"anthropic": { "apiKey": "$ANTHROPIC_API_KEY" },
"openai": { "apiKey": "$OPENAI_API_KEY" }
}
}
```
- **Per-agent overrides** — individual agents in `agents.list[]` can override the default model. See the [OpenClaw model docs](https://docs.openclaw.ai/concepts/models) for details.
2.2 Create Secrets
Generate a gateway authentication token and create the Kubernetes secret:
```bash
# Generate a strong gateway token
GATEWAY_TOKEN=$(openssl rand -hex 32)
echo "Save this token for Control UI access: $GATEWAY_TOKEN"
kubectl create secret generic openclaw-secrets \
--namespace apps-restricted \
--from-literal=ANTHROPIC_API_KEY=<YOUR_ANTHROPIC_API_KEY> \
--from-literal=TELEGRAM_BOT_TOKEN=<YOUR_TELEGRAM_BOT_TOKEN> \
--from-literal=OPENCLAW_GATEWAY_TOKEN=$GATEWAY_TOKEN
```
```bash
GATEWAY_TOKEN=$(openssl rand -hex 32)
echo "Save this token for Control UI access: $GATEWAY_TOKEN"
kubectl create secret generic openclaw-secrets \
--namespace apps-restricted \
--from-literal=ANTHROPIC_API_KEY=<YOUR_ANTHROPIC_API_KEY> \
--from-literal=OPENCLAW_GATEWAY_TOKEN=$GATEWAY_TOKEN
```
<div class="alert alert-primary" role="alert"><div class="h4 alert-heading" role="heading">No API token needed</div>
WhatsApp uses QR-code based linking — no API keys or tokens required. You'll pair your account interactively in [Step 3](#whatsapp-setup).
</div>
```bash
GATEWAY_TOKEN=$(openssl rand -hex 32)
echo "Save this token for Control UI access: $GATEWAY_TOKEN"
kubectl create secret generic openclaw-secrets \
--namespace apps-restricted \
--from-literal=ANTHROPIC_API_KEY=<YOUR_ANTHROPIC_API_KEY> \
--from-literal=OPENCLAW_GATEWAY_TOKEN=$GATEWAY_TOKEN
```
Save the gateway token
The `GATEWAY_TOKEN` is required to access the Control UI dashboard. Save it securely — you'll need it when logging into the web interface.
Each manifest includes a `CiliumNetworkPolicy` that restricts OpenClaw's outbound traffic to only the Anthropic API and the messaging provider's servers. Combined with the namespace-level default-deny policy from the [Kubernetes guide](/kubeclaw/docs/guide/kubernetes/), this means OpenClaw **cannot** reach any other external service. Cilium's DNS proxy intercepts DNS64-synthesized AAAA responses and maps them to the FQDN, so these rules work transparently with NAT64.
Startup time
The pod installs OpenClaw via `npm install -g` on every restart, which takes 1–2 minutes. For faster restarts, build a custom Docker image with OpenClaw baked in (see [Maintenance](#build-a-custom-image-optional)).
This displays a QR code in the terminal. Scan it with your WhatsApp app:
Open WhatsApp on your phone
Go to Settings > Linked Devices > Link a Device
Scan the QR code
Dedicated number recommended
Using a separate WhatsApp number (not your personal one) provides cleaner access boundaries and avoids self-chat confusion. If using your personal number, add `"selfChatMode": true` to the WhatsApp channel configuration.
3.2 Verify Connection
After linking, restart the gateway to pick up the stored credentials:
WhatsApp (via the Baileys library) connects to multiple WhatsApp servers. The key domains include:
web.whatsapp.com
*.whatsapp.net (messaging, media)
WhatsApp credentials persist
WhatsApp credentials are stored at `~/.openclaw/credentials/whatsapp/` on the persistent volume. They survive pod restarts — you only need to scan the QR code once.
Signal Setup
Advanced setup
Signal requires `signal-cli`, a Java-based application, installed in the container. This section requires building a custom Docker image.
Build and push to a registry accessible from your cluster, then update the StatefulSet image field.
3.2 Register the Bot Phone Number
Exec into the pod and register:
kubectl exec -n apps-restricted -it openclaw-0 -- sh
# Register the bot number (may require captcha)signal-cli -a +<BOT_PHONE_NUMBER> register
# If captcha required, visit https://signalcaptchas.org/registration/generate.html# then run:signal-cli -a +<BOT_PHONE_NUMBER> register --captcha '<CAPTCHA_URL>'# Verify with the SMS codesignal-cli -a +<BOT_PHONE_NUMBER> verify <CODE>
3.3 Alternatively: Link an Existing Account
If you prefer to link to an existing Signal account (not recommended for production):
Scan the QR code in your Signal app under Settings > Linked Devices.
Registering deauthenticates
Registering a phone number with `signal-cli` deauthenticates the main Signal app for that number. Use a **dedicated bot number** to avoid losing access to your personal Signal account.
Step 4: Expose Control UI via Cloudflare Tunnel
OpenClaw includes a web-based Control UI (dashboard) for managing sessions, viewing logs, and chatting directly. Since the dashboard is an admin surface, we protect it with two layers: Cloudflare Access (identity verification) and gateway token (application authentication).
4.1 Add Tunnel Route
In Cloudflare Zero Trust > Networks > Tunnels > your tunnel > Public Hostnames, add:
In Cloudflare dashboard under your domain > **SSL/TLS** > **Overview**, set the mode to **Full** (not "Full (strict)") since the origin (OpenClaw gateway) serves HTTP, not HTTPS. Cloudflare terminates TLS at the edge.
4.2 Create Cloudflare Access Policy
Protect the dashboard route so only authenticated users can reach it:
Go to Cloudflare Zero Trust > Access > Applications
Click Add an Application > Self-hosted
Configure:
Field
Value
Application name
OpenClaw Dashboard
Session Duration
24 hours
Application domain
openclaw.yourdomain.com
Add a Policy:
Field
Value
Policy name
Allowed Users
Action
Allow
Include
Emails — your-email@example.com
Under Authentication, select your identity provider or use One-time PIN (sends a verification code to your email — no IdP setup required)
Two-layer authentication
With this setup, accessing `openclaw.yourdomain.com` requires:
1. **Cloudflare Access**: Verifies your identity (email + OTP or IdP login)
2. **Gateway token**: Entered in the Control UI settings panel (the token from [Step 2.2](#22-create-secrets))
Even if someone bypasses Cloudflare Access, they cannot use the dashboard without the gateway token.
4.3 Access the Dashboard
Navigate to https://openclaw.yourdomain.com in your browser
Authenticate with Cloudflare Access (email OTP or your identity provider)
The Control UI loads — enter the gateway token when prompted
The token is stored in your browser’s localStorage for future sessions
The Control UI provides:
Chat: Direct conversation with the AI agent
Sessions: View and manage active sessions across all channels
Channels: Status of connected messaging channels (Telegram, WhatsApp, Signal)
Logs: Live gateway log tailing
Configuration: Edit settings with concurrent edit protection
Skills: Install and manage skills
Step 5: Verify & Test
Test Messaging
1. Find your bot on Telegram
2. Send any message
3. OpenClaw should respond (only to your user ID)
1. Send a message to the linked WhatsApp number
2. OpenClaw should respond (only to numbers in `allowFrom`)
1. Send a message to the bot's Signal number
2. OpenClaw should respond (only to numbers in `allowFrom`)
Test Control UI
Open https://openclaw.yourdomain.com
Verify Cloudflare Access prompts for authentication
Enter the gateway token in the UI
Send a test message in the chat interface
Check Pod Health
# Pod statuskubectl get pods -n apps-restricted
# Logskubectl logs -n apps-restricted -l app=openclaw -f
# PVC statuskubectl get pvc -n apps-restricted
# Exec into pod for diagnosticskubectl exec -n apps-restricted -it openclaw-0 -- openclaw doctor
kubectl exec -n apps-restricted -it openclaw-0 -- openclaw status
Maintenance
Update OpenClaw
The pod installs openclaw@latest on every restart, so a simple restart pulls the newest version:
Practical recipes for managing SSH keys, plus a manual walkthrough of the
infrastructure OpenTofu builds for you.
5.1 - SSH Keys with Passphrase
Protect cluster SSH keys with a passphrase and keep Ansible working through ssh-agent.
Why use a passphrase?
An SSH key without a passphrase is like a house key without a lock on the key cabinet. If your laptop or key file is stolen, the attacker gains immediate access to your cluster.
Creating keys with a passphrase
# Control node keyssh-keygen -t ed25519 -f ~/.ssh/k8s-cluster_control-node_key -C "k8s-control"# Enter a strong passphrase when prompted# Worker node keyssh-keygen -t ed25519 -f ~/.ssh/k8s-cluster_worker-node_key -C "k8s-worker"# Enter a passphrase when prompted
Using ssh-agent
Since Terraform and Ansible cannot directly use encrypted keys, you must use ssh-agent:
# Start agent (if not already running)eval"$(ssh-agent -s)"# Add keys (prompts for passphrase)ssh-add ~/.ssh/k8s-cluster_control-node_key
ssh-add ~/.ssh/k8s-cluster_worker-node_key
# Check which keys are loadedssh-add -l
macOS: Keychain integration
On macOS you can store the passphrase in the system Keychain so the key is automatically available after a reboot:
# Add key AND store passphrase in Keychainssh-add --apple-use-keychain ~/.ssh/k8s-cluster_control-node_key
ssh-add --apple-use-keychain ~/.ssh/k8s-cluster_worker-node_key
Also add the following to ~/.ssh/config:
Host *
UseKeychain yes
AddKeysToAgent yes
Helper script
The project includes a script that sets up ssh-agent correctly:
# Run once before using SSH/Ansiblesource ./scripts/ssh-agent-setup.sh
# Afterwards SSH and Ansible work without further passphrase promptsssh control-node
ansible all -m ping
Encrypting auto-generated keys after export
If you use auto-generated keys from OpenTofu, you can add a passphrase afterwards:
After encryption, `tofu output` still returns the unencrypted key from the state. However, your local key file is now protected.
5.2 - Store SSH Keys in Password Manager
Back up and restore cluster SSH keys using a password manager.
Storing SSH keys in a password manager provides a secure backup that survives hardware failures and makes it easy to restore access from a new machine.
General workflow
This workflow applies to any password manager that supports secure notes or file attachments (Dashlane, 1Password, Bitwarden, etc.).
Content: paste the private key (cat ~/.ssh/k8s-cluster_control-node_key)
Add the public key as an additional field
Optionally attach the key files directly to the secure note
5.3 - Manual Setup (Alternative)
Build the same infrastructure by hand in the Hetzner Console – the steps OpenTofu automates.
This guide is an alternative to OpenTofu
This guide shows the manual steps that OpenTofu automates. Use this if you want to understand what happens behind the scenes, or if you prefer to set up infrastructure manually via the Hetzner Cloud Console. For the automated approach, see [Infrastructure (OpenTofu)](/kubeclaw/docs/guide/infrastructure/).
Overview
This setup creates a secure server infrastructure with the following properties:
No public IPv4/IPv6 addresses (after setup)
SSH access exclusively via Cloudflare Tunnel
Internal communication via Hetzner Private Network
Since the Hetzner Web Console (VNC) has issues with copy/paste (especially on Firefox/macOS), create a temporary admin server with a public IPv6 address for the initial setup.
Cloud-Init for Admin Node
#cloud-configusers:- name:kubernetes-admingroups:users, admin, sudosudo:ALL=(ALL) NOPASSWD:ALLshell:/bin/bashssh_authorized_keys:- ssh-ed25519 AAAA... YOUR_ADMIN_NODE_PUBLIC_KEYkeyboard:layout:devariant:macpackages:- fail2ban- ufwpackage_update:truepackage_upgrade:truewrite_files:- path:/etc/ssh/sshd_config.d/ssh-hardening.confcontent:| PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 3
AllowTcpForwarding yes
X11Forwarding no
AllowAgentForwarding no
AllowUsers kubernetes-admin
ClientAliveInterval 300
ClientAliveCountMax 2- path:/etc/fail2ban/jail.localcontent:| [sshd]
enabled = true
port = 22
banaction = iptables-multiport
maxretry = 3
findtime = 600
bantime = 3600runcmd:- systemctl enable fail2ban- systemctl start fail2ban- ufw allow 22- ufw --force enable- reboot
Create the server
Servers > Add Server
Location: Any (e.g. Falkenstein)
Image: Debian 13
Type: CX23 (smallest size is sufficient)
Networking:
Public IPv6 enabled
Private Network: add your network
SSH Keys: Add Admin Node key
Cloud config: Paste the YAML above
Create & Buy now
Determine IPv6 address
The IPv6 address is displayed in Hetzner only as a subnet (e.g. 2a01:4f8:1c19:c886::/64). The actual server address is typically ::1 appended:
Your local machine needs IPv6 connectivity. Test with `ping6 google.com`.
Step 4: Create Control Node (with Cloudflare Tunnel)
Cloud-Init for Control Node
#cloud-configusers:- name:rootplain_text_passwd:'SECURE_PASSWORD_HERE'lock_passwd:false- name:kubernetes-admingroups:users, admin, sudosudo:ALL=(ALL) NOPASSWD:ALLshell:/bin/bashssh_authorized_keys:- ssh-ed25519 AAAA... YOUR_CONTROL_NODE_PUBLIC_KEYkeyboard:layout:devariant:macpackages:- fail2ban- ufw- curl- wgetpackage_update:truepackage_upgrade:truewrite_files:- path:/etc/ssh/sshd_config.d/ssh-hardening.confcontent:| PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 3
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding yes
AllowUsers kubernetes-admin
ClientAliveInterval 300
ClientAliveCountMax 2- path:/etc/fail2ban/jail.localcontent:| [sshd]
enabled = true
port = 22
banaction = iptables-multiport
maxretry = 3
findtime = 600
bantime = 3600- path:/etc/cloudflared/config.ymlcontent:| edge-ip-version: "6"runcmd:- systemctl enable fail2ban- systemctl start fail2ban- ufw allow from 10.0.0.0/24 to any port 22 proto tcp comment 'SSH internal'- ufw allow from 127.0.0.1 to any port 22 proto tcp comment 'SSH via Tunnel'- ufw default deny incoming- ufw default allow outgoing- ufw --force enable- mkdir -p --mode=0755 /usr/share/keyrings- curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg | tee /usr/share/keyrings/cloudflare-public-v2.gpg >/dev/null- echo 'deb [signed-by=/usr/share/keyrings/cloudflare-public-v2.gpg] https://pkg.cloudflare.com/cloudflared any main' | tee /etc/apt/sources.list.d/cloudflared.list- mkdir -p /etc/cloudflared- apt-get update && apt-get install -y cloudflared- reboot
Create the server
Servers > Add Server
Image: Debian 13
Type: As needed (e.g. CX23 or larger)
Networking:
Public IPv4: Disabled
Public IPv6: Enabled (temporarily, for installation)
Private Network: add your network
SSH Keys: Add Control Node key
Cloud config: Paste the YAML above
Create & Buy now
Important
The `edge-ip-version: "6"` setting is essential! Cloudflared attempts IPv4 connections to Cloudflare's edge servers by default. Since the server is IPv6-only, this must be set to `"6"` (as a string!).
Step 5: Create Worker Node (isolated)
Cloud-Init for Worker Node
#cloud-configusers:- name:rootplain_text_passwd:'SECURE_PASSWORD_HERE'lock_passwd:false- name:kubernetes-admingroups:users, admin, sudosudo:ALL=(ALL) NOPASSWD:ALLshell:/bin/bashssh_authorized_keys:- ssh-ed25519 AAAA... YOUR_WORKER_NODE_PUBLIC_KEYkeyboard:layout:devariant:macpackages:- fail2ban- ufwpackage_update:truepackage_upgrade:truewrite_files:- path:/etc/ssh/sshd_config.d/ssh-hardening.confcontent:| PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 3
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
AllowUsers kubernetes-admin
ClientAliveInterval 300
ClientAliveCountMax 2- path:/etc/fail2ban/jail.localcontent:| [sshd]
enabled = true
port = 22
banaction = iptables-multiport
maxretry = 3
findtime = 600
bantime = 3600runcmd:- systemctl enable fail2ban- systemctl start fail2ban- ufw default deny incoming- ufw default deny outgoing- ufw allow from 10.0.0.0/24 to any port 22 proto tcp comment 'SSH internal'- ufw allow from 10.0.0.0/24 proto icmp comment 'ICMP internal'- ufw allow out to 10.0.0.0/24 comment 'Outbound internal'- ufw allow out to 185.12.64.1 port 53 proto udp comment 'DNS Hetzner'- ufw allow out to 185.12.64.2 port 53 proto udp comment 'DNS Hetzner'- ufw allow out to any port 80 proto tcp comment 'HTTP Updates'- ufw allow out to any port 443 proto tcp comment 'HTTPS Updates'- ufw --force enable- reboot
Enable public IPv6 for replica control nodes and worker nodes. The master always has public IPv6 (required for cloudflared). Setting to false air-gaps replicas and workers.
Server Configuration
Variable
Type
Default
Description
server_image
string
"debian-13"
Server image to use
master_control_node_type
string
"cx23"
Server type for the master control node (runs cloudflared)
control_node_types
list(object({type, count}))
[]
Server types and counts for replica control nodes
worker_node_types
list(object({type, count}))
[]
Server types and counts for worker nodes
The cost-optimized x86 line is cx23, cx33, cx43, and cx53; the Arm
equivalents are cax11 through cax41. See
Cost Estimate for
specifications, current prices, and the constraints on the Arm line.
Install Kubernetes prerequisites (container runtime, kubeadm, kubelet, kubectl) via cloud-init
kubernetes_version
string
"1.32"
Kubernetes minor version for the pkgs.k8s.io apt source
container_runtime
string
"containerd"
Container runtime for Kubernetes nodes: "containerd" or "cri-o"
6.2 - Outputs Reference
Every OpenTofu output, what consumes it, and how to query it.
All outputs exposed by the OpenTofu configuration. These are consumed by the setup scripts and can be queried manually.
Network Information
Output
Description
network_id
ID of the private network
network_name
Name of the private network
Cluster Metadata
Output
Description
cluster_name
Name of the cluster
admin_user
Admin user name
ssh_key_prefix
Prefix used for SSH key filenames
Master Control Node
Output
Description
master_control_node_id
ID of the master control node
master_control_node_name
Name of the master control node
master_control_node_private_ip
Private IP of the master control node
All Control Nodes (master + replicas)
Output
Description
control_node_count
Total number of control nodes (master + replicas)
control_node_ids
IDs of all control nodes
control_node_names
Names of all control nodes
control_node_private_ips
Private IPs of all control nodes
Worker Nodes
Output
Description
worker_node_count
Number of worker nodes
worker_node_ids
IDs of worker nodes
worker_node_names
Names of worker nodes
worker_node_private_ips
Private IPs of worker nodes
Admin Node
Output
Description
admin_node_id
ID of the admin node (null if disabled)
admin_node_name
Name of the admin node (null if disabled)
admin_node_ipv6
Public IPv6 address of the admin node (null if disabled)
admin_node_private_ip
Private IP of the admin node (null if disabled)
enable_admin_node
Whether the admin node is enabled
SSH Keys
These outputs are sensitive when auto-generated keys are used.
Output
Sensitive
Description
control_node_ssh_private_key
Yes
Private SSH key for control nodes (only if auto-generated)
control_node_ssh_public_key
No
Public SSH key for control nodes
worker_node_ssh_private_key
Yes
Private SSH key for worker nodes (only if auto-generated)
worker_node_ssh_public_key
No
Public SSH key for worker nodes
admin_node_ssh_private_key
Yes
Private SSH key for admin node (only if auto-generated)
admin_node_ssh_public_key
No
Public SSH key for admin node
using_custom_keys
No
Map showing which node roles use custom keys
SSH Config
Output
Description
ssh_config_snippet
SSH config snippet for ~/.ssh/config. Generates Host entries for admin-node (ProxyJump), cloudflare tunnel (ProxyCommand), and all nodes. All entries include IdentitiesOnly yes.
Feature Flags
Output
Description
cloudflare_tunnel_domain
Configured Cloudflare Tunnel domain
cloudflare_tunnel_configured
Whether cloudflare_tunnel_token was set, i.e. whether cloud-init installed the tunnel automatically
nat64_enabled
Whether NAT64/DNS64 is enabled
k8s_prereqs_enabled
Whether Kubernetes prerequisites are installed via cloud-init
Next Steps Banner
Output
Description
next_steps
Instructions banner displayed after tofu apply
Querying outputs
# List all outputstofu output
# Get a specific outputtofu output master_control_node_private_ip
# Get a sensitive output (raw)tofu output -raw control_node_ssh_private_key
# Export SSH key to filetofu output -raw control_node_ssh_private_key > ~/.ssh/k8s-cluster_control-node_key
chmod 600 ~/.ssh/k8s-cluster_control-node_key
6.3 - Cloud-Init Templates
The three cloud-init templates, their template variables, and their conditional sections.
Cloud-init templates are located in cloud-init/ and rendered by OpenTofu via templatefile() in main.tf. They configure each server on first boot.
admin-node.yaml.tpl
Purpose: Minimal jump host for initial SSH access.
Used by: hcloud_server.admin_node
Template variables:
Variable
Source
ssh_public_key
local.admin_node_public_key
root_password
var.root_password
admin_user
var.admin_user
keyboard_layout
var.keyboard_layout
What it configures:
Admin user with sudo NOPASSWD
SSH hardening with AllowTcpForwarding yes (needed for ProxyJump)
fail2ban for SSH protection
UFW allowing public SSH (port 22 from anywhere)
control-node.yaml.tpl
Purpose: Kubernetes control plane node with optional Cloudflare Tunnel.
Used by: hcloud_server.master_control_node, hcloud_server.control_node_replica
is_master = true: Installs cloudflared, allows SSH from localhost, creates /etc/cloudflared/config.yml with edge-ip-version: "6". When cloudflare_tunnel_token is set, runs cloudflared service install <token> to auto-configure the tunnel as a systemd service.
The `lifecycle { ignore_changes = [user_data] }` block on all servers prevents re-provisioning when template variables change. Cloud-init only runs on first boot. Use [Ansible playbooks](/kubeclaw/docs/reference/playbooks/) for configuration changes on running nodes.
6.4 - Ansible Playbooks Reference
Purpose, variables, and task list for each Ansible playbook.
All playbooks are in ansible/playbooks/ and target the k8s_cluster host group by default.
update-system.yml
Purpose: Run apt update && apt upgrade on all nodes.
Purpose: Configure DNS64 resolvers and NAT64 routing on already-running nodes. Cloud-init only runs at first boot – use this playbook for existing nodes or to reconfigure.
Purpose: Install container runtime (containerd or CRI-O), kubeadm, kubelet, and kubectl on already-running nodes. Cloud-init only runs at first boot – use this playbook for existing nodes.
Export SSH keys, generate ~/.ssh/config with backup
scripts/ssh-agent-setup.sh
Fix SSH permissions, start ssh-agent, load keys
scripts/generate-ansible-inventory.sh
Build Ansible inventory from OpenTofu state
scripts/build-docs.sh
Build the Hugo site locally
scripts/serve-docs.sh
Preview the site locally
scripts/deploy-docs.sh
Build Hugo and push to gh-pages
Dev Container
The aibox configuration in aibox.toml is the source of truth for generated
Dev Container files. Run aibox apply after changing it; do not hand-edit
.devcontainer/Dockerfile, docker-compose.yml, or devcontainer.json.
Dockerfile.local and docker-compose.override.yml are project-owned
extensions and are safe to edit.
Brand surface styles: type scale, code theme, dark mode, chrome
assets/icons/logo.svg
The projectious mark inlined into the navbar
layouts/partials/favicons.html
Favicon set override (Docsy’s default references assets this project does not ship)
layouts/partials/hooks/head-end.html
Brand web-font loading
static/favicons/
Brand favicon and touch-icon assets
Module mounts
`hugo.yaml` declares `module.mounts` for the vendored Bootstrap and Font Awesome
assets. Declaring any mount for a component replaces Hugo's default mount for
that component, so the config also restores `assets` and `static` explicitly.
Removing those two entries silently disables every project-level brand asset.
6.7 - Cost Estimate
Indicative monthly Hetzner Cloud costs for common cluster sizes.
All figures are list prices in EUR excluding VAT, current as of the Hetzner
price adjustment of 15 June 2026. Hetzner bills hourly; the monthly figure is
the cap you will not exceed. Always check
Hetzner Cloud pricing before committing –
prices changed several times during 2026.
Server types
KubeClaw defaults to the cost-optimized CX line (shared vCPU, x86). The
CAX line offers the same resources on Ampere Arm cores, but is only
available in the German and Finnish locations (fsn1, nbg1, hel1) and
requires arm64 container images throughout the cluster.
Type
vCPU
RAM
NVMe
Monthly (excl. IPv4)
cx23
2
4 GB
40 GB
€5.49
cx33
4
8 GB
80 GB
€8.49
cx43
8
16 GB
160 GB
€15.99
cx53
16
32 GB
320 GB
€29.49
cax11 (Arm)
2
4 GB
40 GB
€5.99
cax21 (Arm)
4
8 GB
80 GB
€10.49
cax31 (Arm)
8
16 GB
160 GB
€20.99
cax41 (Arm)
16
32 GB
320 GB
€40.99
The CX22 generation is retired
Earlier revisions of this project defaulted to `cx22`. That generation
(`cx22`/`cx32`/`cx42`/`cx52`) is no longer offered for new orders -- the
current cost-optimized line is `cx23`/`cx33`/`cx43`/`cx53`. Existing servers
keep their original pricing until they are rescaled or recreated.
The higher-performance CPX (dedicated AMD share) and CCX (fully
dedicated) lines are considerably more expensive – cpx22 is €19.49/month and
ccx13 is €42.99/month – and are rarely worth it for a learning or
small-production cluster.
Additional costs
Item
Price
Primary IPv4 address
€0.50 per server per month
Block storage volume
~€0.0572 per GB per month
Private network
Free
Cloudflare Tunnel + Access
Free (up to 50 users)
IPv6-only is a real saving
KubeClaw provisions every node without a public IPv4 address, which avoids the
€0.50 per server per month primary-IPv4 charge. On a five-node cluster that is
€30/year, and it is the reason [NAT64/DNS64](/kubeclaw/docs/introduction/dns-and-nat64/)
exists in this project.
Example: 2-node cluster
Component
Specification
Monthly Cost
Master control node (cx23, IPv6-only)
2 vCPU / 4 GB RAM
€5.49
Worker node (cx23, IPv6-only)
2 vCPU / 4 GB RAM
€5.49
Block volumes (10 GB each)
Hetzner CSI, 20 GB total
~€1.14
Private network
–
Free
Cloudflare Tunnel + Access
Up to 50 users
Free
Total
~€12.12/month
Scaling costs
Server cost only; add block volumes for any workload that needs persistent
storage.
Configuration
Nodes
Server cost
Master-only (dev/learning)
1
€5.49
Master + 1 worker
2
€10.98
3 control + 2 workers (HA)
5
€27.45
Notes
The admin node (cx23, €5.49/month) is temporary. Set
enable_admin_node = false once the Cloudflare Tunnel works, and the charge
stops – see Quick Start.
Mixed server types are supported, so workers can be sized independently of
the control plane. See Scale Up/Down.
Snapshots and backups are billed separately and are not included above.
7 - Operations
Keep the cluster secure, healthy, and maintainable after deployment.
Day-two procedures for scaling, maintenance, troubleshooting, and security.
7.1 - Scale Up/Down
Add or remove control and worker nodes at the infrastructure and Kubernetes levels.
This guide covers scaling your cluster at both the infrastructure and Kubernetes levels.
After provisioning and running Ansible playbooks on new nodes, join them to the cluster. Follow Step 2 of the Kubernetes guide to run the kubeadm join command.
Remove nodes from Kubernetes
Before removing infrastructure, drain and delete the node from Kubernetes:
Rotate auto-generated or custom SSH keys without losing access.
When to rotate
Periodically (e.g. annually)
If compromise is suspected
When personnel changes occur
With auto-generated keys
# 1. Mark old key resources for recreationtofu taint 'tls_private_key.control_node[0]'tofu taint 'tls_private_key.worker_node[0]'# 2. Generate new keys and update serverstofu apply
# 3. Export new keys./scripts/setup-ssh.sh
Warning
During rotation SSH access may be briefly interrupted. Keep Hetzner web console root access available as a fallback.
With custom keys
# 1. Create new keysssh-keygen -t ed25519 -f ~/.ssh/k8s-control-new -C "control-node-new"# 2. Add the new public key to the server (before removing the old one)ssh control-node
echo"ssh-ed25519 AAAA... control-node-new" >> ~/.ssh/authorized_keys
# 3. Test the new keyssh -i ~/.ssh/k8s-control-new kubernetes-admin@<node-ip>
# 4. Remove the old key from authorized_keysssh -i ~/.ssh/k8s-control-new control-node
# Edit ~/.ssh/authorized_keys and remove the old key line# 5. Update terraform.tfvars with the new public key# control_node_public_key = "ssh-ed25519 AAAA... (new key)"# 6. Sync OpenTofu statetofu apply
Repeat for worker node keys if applicable.
7.3 - Kubernetes Maintenance
Upgrade the control plane and workers, back up volumes, and inspect cluster state.
Operational procedures for upgrading and managing the Kubernetes cluster. For initial cluster setup, see the Kubernetes (kubeadm) guide.
Upgrade Kubernetes
Kubernetes upgrades follow a strict order: control plane first, then workers. This is the standard CKA upgrade workflow.
# 1. From the control plane: drain the workerkubectl drain <worker-name> --ignore-daemonsets --delete-emptydir-data
# 2. On the worker: upgrade packagessudo apt-mark unhold kubeadm kubelet kubectl
sudo apt-get update && sudo apt-get install -y kubeadm=1.33.*-* kubelet=1.33.*-* kubectl=1.33.*-*
sudo apt-mark hold kubeadm kubelet kubectl
# 3. Upgrade node configsudo kubeadm upgrade node
# 4. Restart kubeletsudo systemctl daemon-reload
sudo systemctl restart kubelet
# 5. From the control plane: uncordon the workerkubectl uncordon <worker-name>
Backup PVC data
# Create snapshot via Hetzner Console or API# Hetzner Console → Volumes → Select volume → Create Snapshot
Useful kubectl commands
# Check nodes (dual-stack IPs visible)kubectl get nodes -o wide
# Check all pods across namespaceskubectl get pods -A
# Check pod IPs (should show both IPv4 and IPv6)kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.name}: {.status.podIPs}{"\n"}{end}'# Check storagekubectl get pvc -A
kubectl get pv
# Check CSI driverkubectl get pods -n kube-system | grep hcloud
# Check Cilium statuskubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status
# Check network policieskubectl get ciliumnetworkpolicies -A
# Check FQDN DNS cache (verify DNS64 synthesized addresses are cached)kubectl exec -n kube-system -it \
$(kubectl get pods -n kube-system -l k8s-app=cilium -o name | head -1)\
-- cilium fqdn cache list
# Logs for cloudflared (if running as K8s workload)kubectl logs -n system-unrestricted -l app=cloudflared
# Restart a workloadkubectl rollout restart deployment/my-app -n apps-restricted
7.4 - Security
What the setup protects against, what to monitor, and the exact SSH hardening applied.
Design intent, not a security guarantee
This page describes what the design *intends* to protect against. KubeClaw is
a [prototype](/kubeclaw/docs/project-status/) and has not been
adversarially tested or audited -- assume gaps exist. Treat the table below as
a statement of intent, not of assurance.
Security Summary
Layer
Protection
Network (Hetzner)
Firewall blocks all inbound; IPv6-only, no public IPv4
Network (K8s)
Cilium egress whitelist per namespace/app (FQDN-based)
Access
Cloudflare Tunnel (outbound-only connection, no open ports)
# Check which policies applykubectl get ciliumnetworkpolicies -n apps-restricted
# Check if FQDN rules are resolvingkubectl exec -n kube-system -it \
$(kubectl get pods -n kube-system -l k8s-app=cilium -o name | head -1)\
-c cilium-agent -- cilium-dbg fqdn cache list
Planned improvements and current infrastructure milestones.
Track the next improvements to KubeClaw and its IPv6-first Kubernetes platform.
8.1 - Cilium Dual-Stack Pod Network
Planned work on the Cilium dual-stack pod network.
Implemented
This feature has been implemented. The [Kubernetes guide](/kubeclaw/docs/guide/kubernetes/) now uses dual-stack `kubeadm init` and Cilium with `ipv6.enabled=true` as the standard configuration.
Summary
The cluster uses dual-stack networking (IPv4 + IPv6) in the Cilium pod network. Every pod gets both an IPv4 address (from 10.244.0.0/16) for internal cluster communication and an IPv6 address (from fd00:10:244::/48) for external connectivity via DNS64/NAT64.
This eliminates the need for hostNetwork: true on any pod, which means:
Cilium FQDN egress policies apply to all pods (CoreDNS, CSI controller, OpenClaw)
No port conflicts from hostNetwork bindings
Full pod network isolation is maintained by Cilium
How It Works
kubeadm init with dual-stack CIDRs: --pod-network-cidr=10.244.0.0/16,fd00:10:244::/48 --service-cidr=10.96.0.0/12,fd00:10:96::/108
Cilium with ipv4.enabled=true, ipv6.enabled=true, enableIPv6Masquerade=true
VXLAN tunnel runs over IPv4 underlay (proven, stable – nodes communicate via 10.0.0.0/24)
CoreDNS forwards to DNS64 resolvers (2001:67c:2b0::4) which synthesize AAAA records for IPv4-only domains
Pods route to 64:ff9b::/96 NAT64 addresses via their IPv6 address; Cilium masquerades to the node’s public IPv6
Cilium DNS proxy intercepts DNS64-synthesized AAAA responses and maps them to FQDNs for policy enforcement
Research Findings
The following questions were investigated before implementation:
Question
Answer
Does ipv6.enabled=true require kubeadm dual-stack CIDRs?
Yes – kubeadm must be initialized with both IPv4 and IPv6 CIDRs. CIDRs cannot be changed after init.
Can Cilium manage IPv6 IPAM independently?
Yes (cluster-pool mode), but Kubernetes IPAM with kubeadm-allocated CIDRs is simpler.
Does VXLAN work with IPv6 pod addresses?
Yes – VXLAN tunnel runs over IPv4 underlay (auto mode), encapsulating both IPv4 and IPv6 pod traffic.
Do FQDN egress rules work with DNS64?
Yes – Cilium’s DNS proxy intercepts all DNS responses (A and AAAA). DNS64-synthesized AAAA records are just AAAA records from Cilium’s perspective.
Can pods route to 64:ff9b::/96?
Yes – via enableIPv6Masquerade=true (default). Pod IPv6 traffic is masqueraded to the node’s public IPv6.
Does CoreDNS still need hostNetwork?
No – with dual-stack, CoreDNS has an IPv6 pod address and can reach DNS64 resolvers via masquerade.
Read the contribution guidelines, development setup, and project conventions.
Documentation is built with Hugo and the pinned Docsy theme. Use
./scripts/serve-docs.sh for a local preview and ./scripts/build-docs.sh to
produce the deployable site.
9.1 - Development Setup
Local development workflow for infrastructure changes and the documentation site.
Getting started
Follow the Dev Container guide to set up your
development environment. The aibox Dev Container includes OpenTofu, Ansible,
cloudflared, Hugo, Node.js, Kubernetes clients, and AI assistants.
Documentation development
Preview the documentation site locally:
./scripts/serve-docs.sh
# Open http://localhost:1313
When using a remote Dev Container environment, forward port 1313 from your
editor to view the preview.
Build the site:
./scripts/build-docs.sh
The build uses Hugo’s strict template and content validation. The first run
installs the pinned Docsy asset dependencies locally and initializes the pinned
Docsy theme submodule when needed.
Infrastructure development
If you have a Hetzner Cloud account and want to test infrastructure changes:
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your API tokentofu init
tofu plan # Preview changestofu apply # Apply changes
Project conventions
See How to Contribute for code conventions and the contribution workflow.
Deploy documentation
To deploy the documentation to GitHub Pages:
./scripts/deploy-docs.sh
This is the standard documentation deployment. It builds the Hugo site locally
and pushes the generated public/ directory to the root of the gh-pages
branch. GitHub Pages must be configured to serve gh-pages from /; no GitHub
Actions workflow is required or used.
The build and deployment scripts create an empty .nojekyll marker in the
generated site and at the branch root, so GitHub Pages always serves the
prebuilt output directly instead of processing it with Jekyll.
The same script can publish an archived documentation snapshot under a version
path. For example:
DOCS_VERSION=v0.1 ./scripts/deploy-docs.sh
Add the corresponding entry to params.versions in hugo.yaml when a release
is ready. The versioned build is published below the matching version path and
uses that path as its canonical base URL.
9.2 - Code of Conduct
The behavioural standard this project holds contributors to.