This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

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.

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.

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.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

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)

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 | |

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

3 - Cloudflare Tunnel 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:

PathWhen to useHow it works
Admin node (temporary)Initial setup, before tunnel is readyJump host with public IPv6; SSH via ProxyJump
Cloudflare Tunnel (permanent)After tunnel is configuredcloudflared 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

  1. In the left sidebar, navigate to Networks > Tunnels
  2. Click Create a tunnel
┌──────────────────────────────────────────────────────────────────┐
│  Networks > Tunnels                                              │
│                                                                  │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │                   Create a tunnel                          │  │
│  │                                                            │  │
│  │  Select your tunnel type:                                  │  │
│  │                                                            │  │
│  │  ┌─────────────────────┐   ┌─────────────────────┐       │  │
│  │  │ ● Cloudflared       │   │ ○ WARP Connector     │       │  │
│  │  │   (recommended)     │   │                      │       │  │
│  │  └─────────────────────┘   └─────────────────────┘       │  │
│  │                                                  [ Next ] │  │
│  └────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘
  1. Select Cloudflared and click Next
  2. Enter a tunnel name, e.g. kubeclaw or hetzner-cluster
┌──────────────────────────────────────────────────────────────────┐
│  Name your tunnel                                                │
│                                                                  │
│  Tunnel name:  ┌──────────────────────────────┐                 │
│                │ kubeclaw                      │                 │
│                └──────────────────────────────┘                 │
│                                                                  │
│                                               [ Save tunnel ]   │
└──────────────────────────────────────────────────────────────────┘
  1. 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

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.

  1. You should now be on the Route tunnel screen, or navigate to your tunnel’s Public Hostname tab
  2. Click Add a public hostname
  3. 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 ]  │
└──────────────────────────────────────────────────────────────────┘
FieldValueNotes
SubdomainconsoleOr any name you prefer (e.g. ssh, cluster)
DomainYour Cloudflare-managed domainMust be a domain with Cloudflare DNS
Path(leave empty)Not used for SSH
TypeSSHFrom the dropdown
URLlocalhost:22The tunnel connector runs on the master node, so SSH is on localhost
  1. 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.

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).

  1. In the left sidebar, go to Access > Applications
  2. Click Add an application
  3. 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 ]      │
└──────────────────────────────────────────────────────────────────┘
  1. Click Next to configure a policy
  2. Create an Allow policy:
┌──────────────────────────────────────────────────────────────────┐
│  Add Policies                                                    │
│                                                                  │
│  Policy name:  ┌──────────────────────────┐                     │
│                │ Allow Admin              │                     │
│                └──────────────────────────┘                     │
│                                                                  │
│  Action:  ┌──────────────────────────┐                          │
│           │ Allow                 ▼  │                          │
│           └──────────────────────────┘                          │
│                                                                  │
│  Configure rules:                                                │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │  Include                                                   │  │
│  │  Selector:  ┌─────────────────┐  Value: ┌───────────────┐ │  │
│  │             │ Emails       ▼  │         │ you@email.com │ │  │
│  │             └─────────────────┘         └───────────────┘ │  │
│  └────────────────────────────────────────────────────────────┘  │
│                                                                  │
│                                                    [ Next ]      │
└──────────────────────────────────────────────────────────────────┘
FieldValue
Policy nameAllow Admin
ActionAllow
Include selectorEmails
Include valueYour email address
  1. Click Next, review, and Save

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:

┌──────────────┐     ┌──────────────┐     ┌──────────────────────┐
│  Your        │     │  Cloudflare  │     │  Master Control Node │
│  machine     │────►│  Edge        │────►│  (cloudflared →      │
│  (cloudflared│     │  Network     │     │   localhost:22)      │
│   access ssh)│     │              │     │                      │
└──────────────┘     └──────────────┘     └──────────┬───────────┘
                                                     │ ProxyJump
                                          ┌──────────▼───────────┐
                                          │  Replicas / Workers  │
                                          │  (10.0.0.3+)         │
                                          └──────────────────────┘
TargetSSH commandRoute
Master control nodessh console.yourdomain.orgProxyCommand → Cloudflare → cloudflared → localhost:22
Replica control nodessh control-02ProxyCommand → Cloudflare → master → ProxyJump → 10.0.0.3
Worker nodessh worker-01ProxyCommand → 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

4 - Server Management (Ansible)

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

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

5 - Kubernetes (kubeadm)

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

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

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::/48Dual-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::/108Dual-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

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>

What kubeadm init does behind the scenes:

  1. Generates PKI certificates (CA, API server, kubelet, etc.) in /etc/kubernetes/pki/
  2. Writes static pod manifests for etcd, kube-apiserver, kube-controller-manager, kube-scheduler in /etc/kubernetes/manifests/
  3. Bootstraps etcd and starts the API server
  4. Configures RBAC and creates bootstrap tokens
  5. Generates admin.conf kubeconfig 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

For HA control plane (replica control nodes):

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-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)

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).

Clean up:

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:

  1. CoreDNS is forwarding to DNS64 resolvers (Step 4)
  2. The pod has an IPv6 address (kubectl get pods -n kube-system -o wide | grep hcloud-csi-controller)
  3. 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

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 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

Verify the host can resolve cluster service names:

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:

  1. Go to Zero Trust > Networks > Tunnels > your tunnel > Public Hostnames
  2. Add a new public hostname:
HostnameService
test.yourdomain.comhttp://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

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.

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

ChannelWhat You Need
TelegramBot Token from @BotFather, your Telegram User ID
WhatsAppA phone number with WhatsApp, access to scan a QR code
SignalA 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>
Replace placeholders:

  • <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
```
Apply:

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

  1. Open Telegram and message @BotFather
  2. Send /newbot and follow the prompts
  3. Save the bot token (format: 123456789:ABCdefGHI...)

3.2 Find Your User ID

The safest way (no third-party bots required):

  1. Message your new bot (send any message)
  2. Check the OpenClaw logs:
kubectl logs -n apps-restricted -l app=openclaw -f
  1. Look for your numeric ID in the from.id field 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

  1. Open Telegram and find your bot
  2. Send any message
  3. 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

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:

  1. Open WhatsApp on your phone
  2. Go to Settings > Linked Devices > Link a Device
  3. 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)

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>

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:

HostnameService
openclaw.yourdomain.comhttp://openclaw.apps-restricted.svc.cluster.local:18789

4.2 Create Cloudflare Access Policy

Protect the dashboard route so only authenticated users can reach it:

  1. Go to Cloudflare Zero Trust > Access > Applications
  2. Click Add an Application > Self-hosted
  3. Configure:
FieldValue
Application nameOpenClaw Dashboard
Session Duration24 hours
Application domainopenclaw.yourdomain.com
  1. Add a Policy:
FieldValue
Policy nameAllowed Users
ActionAllow
IncludeEmails — your-email@example.com
  1. 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

  1. Navigate to https://openclaw.yourdomain.com in your browser
  2. Authenticate with Cloudflare Access (email OTP or your identity provider)
  3. The Control UI loads — enter the gateway token when prompted
  4. 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

  1. Open https://openclaw.yourdomain.com
  2. Verify Cloudflare Access prompts for authentication
  3. Enter the gateway token in the UI
  4. 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