Follow these guides to provision the infrastructure, configure the nodes, and deploy OpenClaw on Kubernetes.
This is the multi-page printable view of this section. Click here to print.
Guide
- 1: Dev Container
- 2: Infrastructure (OpenTofu)
- 3: Cloudflare Tunnel Setup
- 4: Server Management (Ansible)
- 5: Kubernetes (kubeadm)
- 6: OpenClaw Deployment
1 - Dev Container
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.
What the Dev Container Provides
- OpenTofu – infrastructure provisioning (Terraform-compatible)
- 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.shat port 1313) - Persistence –
.aibox-home/.ssh/is bind-mounted read-write by this project, sosetup-ssh.shoutput, 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**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
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
2 - Infrastructure (OpenTofu)
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 | |Step 2: Provision Infrastructure
tofu init # First time only
tofu apply
This creates:
- Private network (10.0.0.0/24)
- Role-specific firewalls (control node, worker node, admin node)
- SSH keys (uploaded to Hetzner)
- 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
Next Steps
- Server Management (Ansible) – generate the Ansible inventory, apply system updates and security hardening
- Kubernetes (kubeadm) – deploy the cluster
Related
- SSH Keys with Passphrase – add passphrase protection to SSH keys
- Store SSH Keys in Password Manager – backup keys securely
- SSH Key Rotation – rotate keys periodically or after compromise
- Scale Up/Down – add or remove nodes
- OS Images – available Hetzner Cloud images
3 - Cloudflare Tunnel Setup
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.
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).
┌──────────────────────────────────────────────────────────────────┐
│ Cloudflare | Zero Trust │
├──────────────┬───────────────────────────────────────────────────┤
│ │ │
│ Home │ Zero Trust Overview │
│ Analytics │ │
│ Risk Score │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ Users │ │ Tunnels │ │ Policies │ │
│ Access ► │ │ 0 │ │ 0 │ │ 0 │ │
│ Gateway │ └──────────┘ └──────────┘ └──────────┘ │
│ Networks ► │ │
│ ... │ │
│ │ │
└──────────────┴───────────────────────────────────────────────────┘
Step 2: Create a Tunnel
- In the left sidebar, navigate to Networks > Tunnels
- Click Create a tunnel
┌──────────────────────────────────────────────────────────────────┐
│ Networks > Tunnels │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Create a tunnel │ │
│ │ │ │
│ │ Select your tunnel type: │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ ● Cloudflared │ │ ○ WARP Connector │ │ │
│ │ │ (recommended) │ │ │ │ │
│ │ └─────────────────────┘ └─────────────────────┘ │ │
│ │ [ Next ] │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
- Select Cloudflared and click Next
- Enter a tunnel name, e.g.
kubeclaworhetzner-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— socloud-initauto-installs the tunnel on the master node- Manual install — if the infrastructure is already running
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
- Click Add a public hostname
- Fill in the hostname configuration:
┌──────────────────────────────────────────────────────────────────┐
│ Public Hostnames > Add a public hostname │
│ │
│ Public hostname │
│ ┌──────────────┐ ┌─────────────────────────┐ │
│ │ console │ . │ yourdomain.org ▼ │ │
│ │ (subdomain) │ │ (domain) │ │
│ └──────────────┘ └─────────────────────────┘ │
│ │
│ Path (optional): ┌──────────────────────────┐ │
│ │ │ │
│ └──────────────────────────┘ │
│ │
│ Service │
│ ┌──────────────┐ ┌─────────────────────────┐ │
│ │ SSH ▼ │ │ localhost:22 │ │
│ │ (type) │ │ (URL) │ │
│ └──────────────┘ └─────────────────────────┘ │
│ │
│ [ Save hostname ] │
└──────────────────────────────────────────────────────────────────┘
| Field | Value | Notes |
|---|---|---|
| Subdomain | console | Or any name you prefer (e.g. ssh, cluster) |
| Domain | Your Cloudflare-managed domain | Must be a domain with Cloudflare DNS |
| Path | (leave empty) | Not used for SSH |
| Type | SSH | From the dropdown |
| URL | localhost:22 | 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).
- In the left sidebar, go to Access > Applications
- Click Add an application
- Select Self-hosted
┌──────────────────────────────────────────────────────────────────┐
│ Access > Applications > Add an application │
│ │
│ Application Configuration │
│ │
│ Application name: ┌─────────────────────────┐ │
│ │ KubeClaw SSH Console │ │
│ └─────────────────────────┘ │
│ │
│ Session Duration: ┌─────────────────────────┐ │
│ │ 24 hours ▼ │ │
│ └─────────────────────────┘ │
│ │
│ Application domain: │
│ ┌──────────────┐ ┌─────────────────────────┐ │
│ │ console │ . │ yourdomain.org ▼ │ │
│ └──────────────┘ └─────────────────────────┘ │
│ │
│ [ Next ] │
└──────────────────────────────────────────────────────────────────┘
- Click Next to configure a policy
- Create an Allow policy:
┌──────────────────────────────────────────────────────────────────┐
│ Add Policies │
│ │
│ Policy name: ┌──────────────────────────┐ │
│ │ Allow Admin │ │
│ └──────────────────────────┘ │
│ │
│ Action: ┌──────────────────────────┐ │
│ │ Allow ▼ │ │
│ └──────────────────────────┘ │
│ │
│ Configure rules: │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Include │ │
│ │ Selector: ┌─────────────────┐ Value: ┌───────────────┐ │ │
│ │ │ Emails ▼ │ │ you@email.com │ │ │
│ │ └─────────────────┘ └───────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ [ Next ] │
└──────────────────────────────────────────────────────────────────┘
| Field | Value |
|---|---|
| Policy name | Allow Admin |
| Action | Allow |
| Include selector | Emails |
| Include value | Your email address |
- Click Next, review, and Save
terraform.tfvars, provision the cluster, and verify connectivity.How SSH Routing Works
After the tunnel is active and the admin node is disabled:
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ Your │ │ Cloudflare │ │ Master Control Node │
│ machine │────►│ Edge │────►│ (cloudflared → │
│ (cloudflared│ │ Network │ │ localhost:22) │
│ access ssh)│ │ │ │ │
└──────────────┘ └──────────────┘ └──────────┬───────────┘
│ ProxyJump
┌──────────▼───────────┐
│ Replicas / Workers │
│ (10.0.0.3+) │
└──────────────────────┘
| Target | SSH command | Route |
|---|---|---|
| Master control node | ssh console.yourdomain.org | ProxyCommand → Cloudflare → cloudflared → localhost:22 |
| Replica control node | ssh control-02 | ProxyCommand → Cloudflare → master → ProxyJump → 10.0.0.3 |
| Worker node | ssh worker-01 | ProxyCommand → Cloudflare → master → ProxyJump → 10.0.0.x |
Troubleshooting
Tunnel shows “Inactive” or “Down”
# On the master control node:
sudo systemctl status cloudflared
sudo journalctl -u cloudflared --no-pager -n 50
Common causes:
- Token expired or revoked – create a new tunnel and update
terraform.tfvars - DNS64/NAT64 not working – cloudflared needs outbound connectivity; check with
curl -6 https://cloudflare.com
Browser authentication loop
If ssh console.yourdomain.org keeps opening the browser without connecting:
# Clear cached credentials
cloudflared access login --reset console.yourdomain.org
“connection refused” after authentication
The SSH service on the master node may not be listening on localhost:
# On the master node, verify SSH listens on 127.0.0.1
sudo ss -tlnp | grep 22
The cloud-init template configures UFW to allow SSH from localhost for exactly this reason.
Next Steps
- Infrastructure (OpenTofu) – provision the cluster (if not done yet)
- Server Management (Ansible) – apply updates and hardening
- Kubernetes (kubeadm) – deploy the cluster
4 - Server Management (Ansible)
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 nodesworker_nodes– all worker nodesk8s_cluster– union of control and worker nodes
1.2 Load SSH keys
source ./scripts/ssh-agent-setup.sh
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 nodes
ansible-playbook playbooks/update-system.yml --limit control_nodes
# With reboot if kernel was updated
ansible-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:
ansible-playbook playbooks/configure-nat64.yml
See DNS and NAT64 for details.
Step 5: Install Kubernetes Prerequisites
ansible-playbook playbooks/prepare-k8s-nodes.yml
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.
Next Steps
- Kubernetes (kubeadm) – deploy the cluster
- Ansible Playbooks Reference – detailed playbook documentation
5 - Kubernetes (kubeadm)
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
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
Kernel modules
lsmod | grep -E 'overlay|br_netfilter'
Expected output (both modules present):
br_netfilter ...
overlay ...
Sysctl parameters
sudo sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables net.ipv4.ip_forward
Expected output:
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
Container runtime
```bash
systemctl is-active containerd
```
Expected output: `active````bash
systemctl is-active crio
```
Expected output: `active`
<div class="alert alert-primary" role="alert"><div class="h4 alert-heading" role="heading">CRI-O CNI path</div>
CRI-O uses `/opt/cni/bin` for CNI binaries (the upstream default), unlike Debian's containerd which uses `/usr/lib/cni`. When installing Cilium with CRI-O, use `--set cni.binPath=/opt/cni/bin` (or omit the flag, since `/opt/cni/bin` is Cilium's default).
</div>kubeadm version -o yaml
Expected output (version numbers will vary):
clientVersion:
gitVersion: v1.32.x
platform: linux/amd64
...
1.2 Determine the node’s IPv6 address
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)
sudo kubeadm init \
--apiserver-advertise-address=$MASTER_IP \
--pod-network-cidr=10.244.0.0/16,fd00:10:244::/48 \
--service-cidr=10.96.0.0/12,fd00:10:96::/108 \
--skip-phases=addon/kube-proxy
Flags explained:
--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
[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>
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.confkubeconfig for cluster administration
1.4 Set up kubeconfig
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
1.5 Configure kubelet for dual-stack
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:
NAME READY STATUS RESTARTS AGE
coredns-xxxxxxxxxx-xxxxx 0/1 Pending 0 XXm
coredns-xxxxxxxxxx-xxxxx 0/1 Pending 0 XXm
etcd-<cluster>-control-01 1/1 Running 0 XXm
kube-apiserver-<cluster>-control-01 1/1 Running 0 XXm
kube-controller-manager-<cluster>-control-01 1/1 Running 0 XXm
kube-scheduler-<cluster>-control-01 1/1 Running 0 XXm
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 IPs
WORKER_IPV4=10.0.0.X # Replace with the worker's private IP
WORKER_IPV6=$(ip -6 addr show scope global | grep -oP '(?<=inet6\s)[\da-f:]+' | head -1)
Then join:
sudo kubeadm join <MASTER_IP>:6443 --token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH>
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
sudo kubeadm join <MASTER_IP>:6443 --token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH> \
--control-plane --certificate-key <CERT_KEY>
Generate the certificate key on the master: sudo kubeadm init phase upload-certs --upload-certs
Then configure --node-ip the same way as for workers.
Step 3: Install Cilium CNI (Dual-Stack)
3.1 Install Helm
On the control node, install Helm via the official install script:
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
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):
helm repo add cilium https://helm.cilium.io/
helm repo update
helm install cilium cilium/cilium \
--version 1.16.5 \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=$MASTER_IP \
--set k8sServicePort=6443 \
--set ipam.mode=kubernetes \
--set ipv4.enabled=true \
--set ipv6.enabled=true \
--set enableIPv6Masquerade=true \
--set operator.replicas=1 \
--set cni.binPath=/usr/lib/cni
Flags explained:
--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-proxyfrom 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 owncluster-poolallocator with CIDRs10.0.0.0/8andfd00::/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/cniinstead of the default/opt/cni/bin/. Without this, kubelet reportscni plugin not initialized. If using CRI-O, omit this flag (CRI-O uses the default/opt/cni/bin)
3.3 Wait and verify
# Wait for the Cilium DaemonSet to roll out
kubectl -n kube-system rollout status daemonset/cilium --timeout=120s
# Verify Cilium pods are running
kubectl get pods -n kube-system -l k8s-app=cilium
After containerd restarts and Cilium is ready, all nodes should show Ready:
kubectl get nodes
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
Check the pod’s IP addresses:
kubectl get pods -n kube-system -l k8s-app=kube-dns -o jsonpath='{range .items[*]}{.metadata.name}: {.status.podIPs}{"\n"}{end}'
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.
4.2 Restart CoreDNS
kubectl -n kube-system rollout restart deployment coredns
kubectl -n kube-system rollout status deployment coredns --timeout=60s
4.3 Verify DNS resolution
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 exec test -- nslookup kubernetes.default.svc.cluster.local
# Test external DNS (should return a synthesized AAAA from the DNS64 resolver)
kubectl exec test -- nslookup github.com
# Install curl and test end-to-end NAT64 connectivity
kubectl exec test -- apk add --no-cache curl
kubectl exec test -- 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).
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 token
kubectl create secret generic hcloud \
--namespace kube-system \
--from-literal=token=<YOUR_HETZNER_CSI_API_TOKEN>
# Deploy CSI driver
kubectl apply -f https://raw.githubusercontent.com/hetznercloud/csi-driver/main/deploy/kubernetes/hcloud-csi.yml
# Set hcloud-volumes as default storage class
kubectl 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.1 Create namespaces
# namespaces.yaml
apiVersion: v1
kind: Namespace
metadata:
name: system-unrestricted
labels:
egress-policy: unrestricted
---
apiVersion: v1
kind: Namespace
metadata:
name: apps-restricted
labels:
egress-policy: restricted
kubectl apply -f namespaces.yaml
- system-unrestricted: For infrastructure services (cloudflared, monitoring) that need full network access.
- apps-restricted: For application workloads with egress locked down to specific destinations.
6.2 Default deny egress for restricted namespace
# default-deny-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: apps-restricted
spec:
podSelector: {}
policyTypes:
- Egress
egress: []
kubectl apply -f default-deny-egress.yaml
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.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: example-app-egress
namespace: apps-restricted
spec:
endpointSelector:
matchLabels:
app: my-app
egress:
# DNS resolution (required for FQDN rules)
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- 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
6.4 Unrestricted egress for system namespace
# system-unrestricted-egress.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-all-egress
namespace: system-unrestricted
spec:
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 namespace
kubectl 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 pod
exit
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 up
kubectl 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.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-test
namespace: apps-restricted
spec:
replicas: 1
selector:
matchLabels:
app: nginx-test
template:
metadata:
labels:
app: nginx-test
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "64Mi"
cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
name: nginx-test
namespace: apps-restricted
spec:
selector:
app: nginx-test
ports:
- port: 80
targetPort: 80
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: nginx-test-egress
namespace: apps-restricted
spec:
endpointSelector:
matchLabels:
app: nginx-test
egress:
# DNS resolution (required for most real applications)
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- 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 ClusterIP
COREDNS_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
curl http://nginx-test.apps-restricted.svc.cluster.local
You should see the Nginx welcome page.
7.3 Configure the tunnel hostname
In the Cloudflare dashboard:
- Go to Zero Trust > Networks > Tunnels > your tunnel > Public Hostnames
- Add a new public hostname:
| Hostname | Service |
|---|---|
test.yourdomain.com | http://nginx-test.apps-restricted.svc.cluster.local:80 |
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)
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.
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:
# cloudflared.yaml
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-token
namespace: system-unrestricted
type: Opaque
stringData:
token: "<YOUR_CLOUDFLARE_TUNNEL_TOKEN>"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: system-unrestricted
spec:
replicas: 2
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- run
- --token
- $(TUNNEL_TOKEN)
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-token
key: token
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"
kubectl apply -f cloudflared.yaml
Then disable the system-level service:
ssh control-node sudo systemctl stop cloudflared
ssh control-node sudo systemctl disable cloudflared
Next Steps
For upgrade procedures and operational kubectl commands, see Kubernetes Maintenance.
6 - OpenClaw Deployment
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.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ Hetzner Private Network │
│ (10.0.0.0/24) │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Kubernetes Cluster (kubeadm) + Cilium │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ control-01 │ │ worker-01 │ │ │
│ │ │ (control plane)│◄─────►│ (worker node) │ │ │
│ │ │ 10.0.0.2 │ │ 10.0.0.3 │ │ │
│ │ │ │ │ │ │ │
│ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │
│ │ │ │Block Vol │ │ │ │Block Vol │ │ │ │
│ │ │ │ 10 GB │ │ │ │ 10 GB │ │ │ │
│ │ │ └───────────┘ │ │ └───────────┘ │ │ │
│ │ └────────┬────────┘ └─────────────────┘ │ │
│ │ │ │ │
│ │ system-unrestricted namespace: │ │
│ │ └─ cloudflared (egress: ANY) │ │
│ │ │ │
│ │ apps-restricted namespace: │ │
│ │ └─ OpenClaw (Cilium FQDN egress whitelist) │ │
│ │ ├─ Telegram / WhatsApp / Signal │ │
│ │ └─ Control UI (:18789) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ cloudflared │
│ (outbound only) │
└──────────────────────────────┼──────────────────────────────────────┘
│
▼
┌───────────────────┐
│ Cloudflare │
│ Edge + Access │
└─────────┬─────────┘
│
┌─────────┼─────────┐
│ │ │
▼ ▼ ▼
[Telegram] [WhatsApp] [Signal]
│
▼
[Control UI]
(browser dashboard)
Prerequisites
- A running KubeClaw cluster (see Kubernetes guide)
- Anthropic API key (console.anthropic.com)
- At least one messaging channel:
| Channel | What You Need |
|---|---|
| Telegram | Bot Token from @BotFather, your Telegram User ID |
| A phone number with WhatsApp, access to scan a QR code | |
| Signal | A dedicated phone number, signal-cli installed in the container |
Step 1: Infrastructure Setup
If you haven’t already set up the Hetzner Cloud infrastructure, follow the Quick Start and Kubernetes guide.
This guide assumes you have:
- A running Kubernetes cluster with Cilium CNI
- Hetzner CSI driver installed
- Namespaces created (
system-unrestricted,apps-restricted) - CoreDNS forwarding to DNS64 resolvers (see Kubernetes guide, Step 4)
- Cloudflare Tunnel configured on the master control node
Step 2: Deploy OpenClaw
2.1 Create OpenClaw Configuration
OpenClaw uses JSON5 configuration (supports comments and trailing commas). Create a ConfigMap with the base configuration:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: openclaw-config
namespace: apps-restricted
data:
openclaw.json: |
{
// Model configuration
"agents": {
"defaults": {
"model": {
// Primary model for conversations
"primary": "anthropic/claude-sonnet-4-5-20250929",
// Fallback chain: tried in order if the primary fails
// (rate limit, auth error, timeout, outage)
"fallbacks": [
"anthropic/claude-haiku-4-5-20251001"
]
},
// Aliases appear in the Control UI model selector
"models": {
"anthropic/claude-sonnet-4-5-20250929": { "alias": "Sonnet" },
"anthropic/claude-haiku-4-5-20251001": { "alias": "Haiku" },
"anthropic/claude-opus-4-6": { "alias": "Opus" }
}
}
},
// Provider credentials
"models": {
"providers": {
"anthropic": { "apiKey": "$ANTHROPIC_API_KEY" }
}
},
// Messaging channel
"channels": {
"telegram": {
"enabled": true,
"dmPolicy": "allowlist",
"allowFrom": ["<YOUR_TELEGRAM_USER_ID>"]
}
},
// Gateway authentication (required for Control UI)
"gateway": {
"port": 18789,
"bind": "lan",
"auth": {
"mode": "token"
},
"controlUi": {
"enabled": true
}
}
}
``````yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: openclaw-config
namespace: apps-restricted
data:
openclaw.json: |
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-sonnet-4-5-20250929",
"fallbacks": [
"anthropic/claude-haiku-4-5-20251001"
]
},
"models": {
"anthropic/claude-sonnet-4-5-20250929": { "alias": "Sonnet" },
"anthropic/claude-haiku-4-5-20251001": { "alias": "Haiku" },
"anthropic/claude-opus-4-6": { "alias": "Opus" }
}
}
},
"models": {
"providers": {
"anthropic": { "apiKey": "$ANTHROPIC_API_KEY" }
}
},
"channels": {
"whatsapp": {
"enabled": true,
"dmPolicy": "allowlist",
"allowFrom": ["<YOUR_PHONE_E164>"]
}
},
"gateway": {
"port": 18789,
"bind": "lan",
"auth": {
"mode": "token"
},
"controlUi": {
"enabled": true
}
}
}
```
<div class="alert alert-primary" role="alert"><div class="h4 alert-heading" role="heading">Phone number format</div>
Use E.164 format for `allowFrom`, e.g. `"+15551234567"` (with country code, no spaces).
</div>```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: openclaw-config
namespace: apps-restricted
data:
openclaw.json: |
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-sonnet-4-5-20250929",
"fallbacks": [
"anthropic/claude-haiku-4-5-20251001"
]
},
"models": {
"anthropic/claude-sonnet-4-5-20250929": { "alias": "Sonnet" },
"anthropic/claude-haiku-4-5-20251001": { "alias": "Haiku" },
"anthropic/claude-opus-4-6": { "alias": "Opus" }
}
}
},
"models": {
"providers": {
"anthropic": { "apiKey": "$ANTHROPIC_API_KEY" }
}
},
"channels": {
"signal": {
"enabled": true,
"account": "<BOT_PHONE_E164>",
"cliPath": "signal-cli",
"dmPolicy": "allowlist",
"allowFrom": ["<YOUR_PHONE_E164>"]
}
},
"gateway": {
"port": 18789,
"bind": "lan",
"auth": {
"mode": "token"
},
"controlUi": {
"enabled": true
}
}
}
```
<div class="alert alert-primary" role="alert"><div class="h4 alert-heading" role="heading">Signal requires a custom container image</div>
Signal integration requires `signal-cli` (a Java application) installed in the container. See [Step 3: Signal Setup](#signal-setup) for details.
</div><YOUR_TELEGRAM_USER_ID>— your numeric Telegram user ID (see Step 3: Telegram Setup)<YOUR_PHONE_E164>— your phone number in E.164 format (e.g.+15551234567)<BOT_PHONE_E164>— dedicated phone number for the Signal bot
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
```2.3 Deploy OpenClaw StatefulSet
Create file openclaw.yaml:
```yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: openclaw
namespace: apps-restricted
spec:
serviceName: openclaw
replicas: 1
selector:
matchLabels:
app: openclaw
template:
metadata:
labels:
app: openclaw
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: openclaw
image: node:22-slim
workingDir: /app
command:
- /bin/sh
- -c
- |
npm install -g openclaw@latest &&
openclaw gateway --port 18789
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: ANTHROPIC_API_KEY
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: TELEGRAM_BOT_TOKEN
- name: OPENCLAW_GATEWAY_TOKEN
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: OPENCLAW_GATEWAY_TOKEN
- name: OPENCLAW_CONFIG_PATH
value: /etc/openclaw/openclaw.json
- name: OPENCLAW_STATE_DIR
value: /home/node/.openclaw
ports:
- containerPort: 18789
name: gateway
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "2000m"
volumeMounts:
- name: config
mountPath: /etc/openclaw/openclaw.json
subPath: openclaw.json
readOnly: true
- name: data
mountPath: /home/node/.openclaw
volumes:
- name: config
configMap:
name: openclaw-config
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: hcloud-volumes
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: openclaw
namespace: apps-restricted
spec:
selector:
app: openclaw
ports:
- port: 18789
targetPort: 18789
name: gateway
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: openclaw-egress
namespace: apps-restricted
spec:
endpointSelector:
matchLabels:
app: openclaw
egress:
# DNS resolution (required for FQDN rules)
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Allow internal cluster communication
- toEntities:
- cluster
# Anthropic API
- toFQDNs:
- matchName: "api.anthropic.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
# Telegram API
- toFQDNs:
- matchName: "api.telegram.org"
toPorts:
- ports:
- port: "443"
protocol: TCP
``````yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: openclaw
namespace: apps-restricted
spec:
serviceName: openclaw
replicas: 1
selector:
matchLabels:
app: openclaw
template:
metadata:
labels:
app: openclaw
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: openclaw
image: node:22-slim
workingDir: /app
command:
- /bin/sh
- -c
- |
npm install -g openclaw@latest &&
openclaw gateway --port 18789
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: ANTHROPIC_API_KEY
- name: OPENCLAW_GATEWAY_TOKEN
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: OPENCLAW_GATEWAY_TOKEN
- name: OPENCLAW_CONFIG_PATH
value: /etc/openclaw/openclaw.json
- name: OPENCLAW_STATE_DIR
value: /home/node/.openclaw
ports:
- containerPort: 18789
name: gateway
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "2000m"
volumeMounts:
- name: config
mountPath: /etc/openclaw/openclaw.json
subPath: openclaw.json
readOnly: true
- name: data
mountPath: /home/node/.openclaw
volumes:
- name: config
configMap:
name: openclaw-config
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: hcloud-volumes
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: openclaw
namespace: apps-restricted
spec:
selector:
app: openclaw
ports:
- port: 18789
targetPort: 18789
name: gateway
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: openclaw-egress
namespace: apps-restricted
spec:
endpointSelector:
matchLabels:
app: openclaw
egress:
# DNS resolution (required for FQDN rules)
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Allow internal cluster communication
- toEntities:
- cluster
# Anthropic API
- toFQDNs:
- matchName: "api.anthropic.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
# WhatsApp servers
- toFQDNs:
- matchName: "web.whatsapp.com"
- matchPattern: "*.whatsapp.net"
- matchPattern: "*.whatsapp.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "5222"
protocol: TCP
``````yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: openclaw
namespace: apps-restricted
spec:
serviceName: openclaw
replicas: 1
selector:
matchLabels:
app: openclaw
template:
metadata:
labels:
app: openclaw
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: openclaw
image: your-registry/openclaw-signal:latest # Custom image with signal-cli
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: ANTHROPIC_API_KEY
- name: OPENCLAW_GATEWAY_TOKEN
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: OPENCLAW_GATEWAY_TOKEN
- name: OPENCLAW_CONFIG_PATH
value: /etc/openclaw/openclaw.json
- name: OPENCLAW_STATE_DIR
value: /home/node/.openclaw
ports:
- containerPort: 18789
name: gateway
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "2000m"
volumeMounts:
- name: config
mountPath: /etc/openclaw/openclaw.json
subPath: openclaw.json
readOnly: true
- name: data
mountPath: /home/node/.openclaw
volumes:
- name: config
configMap:
name: openclaw-config
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: hcloud-volumes
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: openclaw
namespace: apps-restricted
spec:
selector:
app: openclaw
ports:
- port: 18789
targetPort: 18789
name: gateway
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: openclaw-egress
namespace: apps-restricted
spec:
endpointSelector:
matchLabels:
app: openclaw
egress:
# DNS resolution (required for FQDN rules)
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Allow internal cluster communication
- toEntities:
- cluster
# Anthropic API
- toFQDNs:
- matchName: "api.anthropic.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
# Signal servers
- toFQDNs:
- matchName: "chat.signal.org"
- matchName: "storage.signal.org"
- matchName: "cdn.signal.org"
- matchName: "cdn2.signal.org"
- matchName: "contentproxy.signal.org"
toPorts:
- ports:
- port: "443"
protocol: TCP
```kubectl apply -f openclaw-config.yaml
kubectl apply -f openclaw.yaml
2.4 Verify Deployment
# Watch pod status (wait for Running)
kubectl get pods -n apps-restricted -w
# Check logs (wait for "gateway listening" message)
kubectl logs -n apps-restricted -l app=openclaw -f
# Check persistent volume
kubectl get pvc -n apps-restricted
The gateway is ready when you see output like:
gateway listening on 0.0.0.0:18789
Step 3: Configure Messaging Channel
Telegram Setup
3.1 Create a Bot
- Open Telegram and message @BotFather
- Send
/newbotand follow the prompts - Save the bot token (format:
123456789:ABCdefGHI...)
3.2 Find Your User ID
The safest way (no third-party bots required):
- Message your new bot (send any message)
- Check the OpenClaw logs:
kubectl logs -n apps-restricted -l app=openclaw -f
- Look for your numeric ID in the
from.idfield of the incoming message log
Alternatively, query the Telegram Bot API directly:
curl "https://api.telegram.org/bot<BOT_TOKEN>/getUpdates"
3.3 Update Configuration
Edit the ConfigMap with your actual user ID:
kubectl edit configmap openclaw-config -n apps-restricted
Replace <YOUR_TELEGRAM_USER_ID> with your numeric ID, then restart:
kubectl rollout restart statefulset/openclaw -n apps-restricted
3.4 Test
- Open Telegram and find your bot
- Send any message
- If using
dmPolicy: "pairing", approve the pairing request:
kubectl exec -n apps-restricted -it openclaw-0 -- openclaw pairing list telegram
kubectl exec -n apps-restricted -it openclaw-0 -- openclaw pairing approve telegram <CODE>
If using dmPolicy: "allowlist" (recommended), your user ID is pre-approved and the bot responds immediately.
WhatsApp Setup
3.1 Link Your WhatsApp Account
WhatsApp uses QR-code based pairing — no API keys needed. Exec into the pod and run the login command:
kubectl exec -n apps-restricted -it openclaw-0 -- openclaw channels login --channel whatsapp
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
3.2 Verify Connection
After linking, restart the gateway to pick up the stored credentials:
kubectl rollout restart statefulset/openclaw -n apps-restricted
Check logs for WhatsApp connection:
kubectl logs -n apps-restricted -l app=openclaw -f
3.3 Network Policy Considerations
WhatsApp (via the Baileys library) connects to multiple WhatsApp servers. The key domains include:
web.whatsapp.com*.whatsapp.net(messaging, media)
WhatsApp credentials persistWhatsApp 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
3.1 Build a Custom Image with signal-cli
Create a Dockerfile.openclaw-signal:
FROM node:22-slim
# Install signal-cli native build
RUN apt-get update && apt-get install -y curl && \
VERSION=$(curl -Ls -o /dev/null -w '%{url_effective}' \
https://github.com/AsamK/signal-cli/releases/latest | \
sed -e 's/^.*\/v//') && \
curl -L -O "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" && \
tar xf "signal-cli-${VERSION}-Linux-native.tar.gz" -C /opt && \
ln -sf /opt/signal-cli /usr/local/bin/signal-cli && \
rm -f "signal-cli-${VERSION}-Linux-native.tar.gz" && \
apt-get remove -y curl && apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Install OpenClaw globally
RUN npm install -g openclaw@latest
USER 1000
WORKDIR /app
CMD ["openclaw", "gateway", "--port", "18789"]
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 code
signal-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):
kubectl exec -n apps-restricted -it openclaw-0 -- signal-cli link -n "OpenClaw"
Scan the QR code in your Signal app under Settings > Linked Devices.
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:
| Hostname | Service |
|---|---|
openclaw.yourdomain.com | http://openclaw.apps-restricted.svc.cluster.local:18789 |
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)
4.3 Access the Dashboard
- Navigate to
https://openclaw.yourdomain.comin 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
localStoragefor 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 status
kubectl get pods -n apps-restricted
# Logs
kubectl logs -n apps-restricted -l app=openclaw -f
# PVC status
kubectl get pvc -n apps-restricted
# Exec into pod for diagnostics
kubectl 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:
kubectl rollout restart statefulset/openclaw -n apps-restricted
Rotate Secrets
# Generate new gateway token
NEW_GATEWAY_TOKEN=$(openssl rand -hex 32)
echo "New gateway token: $NEW_GATEWAY_TOKEN"
# Delete and recreate the secret (adjust for your channel)
kubectl delete secret openclaw-secrets -n apps-restricted
kubectl create secret generic openclaw-secrets \
--namespace apps-restricted \
--from-literal=ANTHROPIC_API_KEY=<YOUR_KEY> \
--from-literal=TELEGRAM_BOT_TOKEN=<YOUR_TOKEN> \
--from-literal=OPENCLAW_GATEWAY_TOKEN=$NEW_GATEWAY_TOKEN
# Restart to pick up new secrets
kubectl rollout restart statefulset/openclaw -n apps-restricted
Build a Custom Image (Optional)
For faster pod restarts (skipping npm install on every boot), build a custom image:
FROM node:22-slim
RUN npm install -g openclaw@latest
USER 1000
WORKDIR /app
CMD ["openclaw", "gateway", "--port", "18789"]
Useful Commands
# OpenClaw logs
kubectl logs -n apps-restricted -l app=openclaw -f
# Restart OpenClaw
kubectl rollout restart statefulset/openclaw -n apps-restricted
# Run diagnostics
kubectl exec -n apps-restricted -it openclaw-0 -- openclaw doctor
# Check channel status
kubectl exec -n apps-restricted -it openclaw-0 -- openclaw channels status --probe
# Interactive shell
kubectl exec -n apps-restricted -it openclaw-0 -- sh
# Check Cilium endpoint status (from control node)
cilium endpoint list
# Check FQDN cache (from control node)
kubectl exec -n kube-system -it \
$(kubectl get pods -n kube-system -l k8s-app=cilium -o name | head -1) \
-- cilium fqdn cache list
Configuration Changes
To update the OpenClaw configuration:
# Edit the ConfigMap
kubectl edit configmap openclaw-config -n apps-restricted
# Restart to apply
kubectl rollout restart statefulset/openclaw -n apps-restricted
Alternatively, update the YAML file and reapply:
kubectl apply -f openclaw-config.yaml
kubectl rollout restart statefulset/openclaw -n apps-restricted