djuntgen@juntgen.com
← all posts

OpenClaw on Dedicated VM: IaC-First AI Agent Deployment

#homelab#ai#openclaw#ansible#security#proxmox

The Problem / Motivation

Running an AI agent framework in a homelab introduces a new class of risk that deserves careful thought. OpenClaw — the agent framework powering our homelab AI experiments — has the ability to execute tool calls, spawn subprocesses, and interact with external APIs. That’s exactly what makes it powerful, and exactly what makes containment important.

The tempting path would be to throw it in a Docker container on docker-host alongside our production stacks. Quick, easy, done. But we’ve been down that road with other services and learned the lesson: shared Docker hosts mean shared blast radius. If an AI agent goes sideways — runaway tool calls, a compromised dependency, a misconfigured sandbox — we don’t want that on the same host as Caddy, Portainer, and Open WebUI.

So: dedicated VM, IaC from day one, security hardening before the first prompt.

What We Built

A fully reproducible Ansible-managed deployment of OpenClaw on a dedicated Proxmox VM:

openclaw  10.0.0.14  Ubuntu 24.04 LTS
├── Node.js 22 (NodeSource)
├── Docker CE (sandbox execution only)
├── OpenClaw (npm -g, systemd service)
├── UFW (deny all in, allow 22 from LAN)
└── Gateway: 127.0.0.1:18789 (SSH tunnel only)

Everything is version-controlled in our docker-homelab repo under ansible/roles/openclaw/.

Architecture Decisions

Dedicated VM, Not Docker-on-Docker-host

This was the first decision and the most important one. By giving OpenClaw its own VM:

  • Blast radius containment: A misbehaving agent can’t affect production stacks
  • No Docker socket exposure: The agent never needs access to the host Docker daemon
  • Clean separation of concerns: AI research doesn’t touch production infrastructure
  • Easier audit: One VM, one purpose, one systemd unit to inspect

The cost? A few hundred MB of RAM and 32GB of disk. Absolutely worth it.

Native Node.js + systemd, Not Docker

This might seem counterintuitive given that we Docker everything. But for OpenClaw specifically, native install makes more sense:

  1. Docker is reserved for sandbox execution — OpenClaw uses Docker to run tool calls in isolated containers (network: none). If OpenClaw itself ran in Docker, we’d need Docker-in-Docker or socket passthrough — both are messy.
  2. systemd gives us clean service management — restart policies, journal logging, dependency ordering.
  3. Simpler mental model — one process, one service, no container indirection.

Gateway Bound to 127.0.0.1

The OpenClaw Control UI gateway listens exclusively on 127.0.0.1:18789. It is never exposed to the network — not via UFW hole, not via Caddy reverse proxy. Access is via SSH port forward only:

ssh -L 18789:localhost:18789 <user>@openclaw
# http://localhost:18789

This means:

  • No auth bypass via network-adjacent attacks
  • No need to harden the Control UI itself (it’s localhost from the browser’s perspective)
  • SSH key auth on the tunnel provides the only access gate

If we eventually want openclaw.example.com on the LAN, we’ll add a Caddy entry with local_only restriction. For now, the tunnel is fine.

Sandbox: Docker with network=none

OpenClaw can execute tool calls inside ephemeral Docker containers. We configure the sandbox image as openclaw-sandbox:bookworm-slim with network: none. This means:

  • Tool execution is isolated from the host network
  • No outbound calls from sandbox containers (API calls go through the agent process itself)
  • Even if a tool call is malicious, it can’t exfiltrate data via network

The IaC Implementation

The Ansible role follows our established patterns:

ansible/roles/openclaw/
├── tasks/
│   ├── main.yml      # import_tasks orchestration
│   ├── node.yml      # NodeSource Node.js 22
│   ├── docker.yml    # Docker CE (sandbox only)
│   ├── install.yml   # npm install -g openclaw
│   ├── configure.yml # config.json + .env (mode 600)
│   ├── sandbox.yml   # build openclaw-sandbox image
│   ├── firewall.yml  # UFW day-0 hardening
│   └── service.yml   # systemd enable + start
├── handlers/
│   └── main.yml      # restart openclaw
└── templates/
    ├── config.json.j2   # gateway, llm, sandbox config
    └── openclaw.env.j2  # ANTHROPIC_API_KEY (vault-sourced)

Secrets are managed via Ansible Vault. The ANTHROPIC_API_KEY and OPENCLAW_GATEWAY_TOKEN live in inventory/group_vars/all/vault.yml (encrypted) and are templated into ~/.openclaw/.env with mode: 0600. They never appear in config.json or any git-tracked file.

Day-0 Security Hardening

Before OpenClaw was even installed, the firewall was the first thing we configured:

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp from 10.0.0.0/24 comment 'SSH from LAN only'
ufw enable

Port 18789 never gets a UFW rule — it’s localhost-only at the application layer. Defense in depth: even if UFW were misconfigured, the gateway wouldn’t accept external connections.

The base role (SSH hardening from our common playbook) runs before the openclaw role, so by the time OpenClaw is installed, the VM already has:

  • Password auth disabled
  • Root login disabled
  • Only <user> with key-based auth

Deployment

# First time (bootstrap user + SSH):
ansible-playbook playbooks/bootstrap.yml --limit openclaw -e ansible_user=root

# Deploy OpenClaw:
ansible-playbook playbooks/openclaw.yml --ask-vault-pass

Full idempotent re-runs are safe — Ansible’s module design ensures it only changes what needs changing.

Lessons Learned

1. IaC before the first npm install

It’s tempting to SSH in and start typing commands. Resist. Getting the Ansible role written first means every subsequent change is tracked, reproducible, and auditable. When we upgrade OpenClaw in six months, it’s one playbook run, not a hunt through terminal history.

2. Vault from day zero

We’ve been burned before by secrets committed to git. The vault structure was established before any secrets existed — even placeholder values got the vault treatment. The discipline of “no secret touches a file unless it’s vault-encrypted or mode 600” pays off immediately.

3. The sandbox image matters

openclaw sandbox-setup builds a Docker image that all tool executions run inside. Getting this right early — network: none, minimal base image (bookworm-slim) — means we’re not retrofitting security controls later.

4. SSH tunnel is underrated

We initially debated whether to expose the Control UI via Caddy. The SSH tunnel approach is actually superior for a research/experimentation phase: it requires no additional firewall rules, no Caddy config, no TLS cert management, and the SSH key IS the auth. We can always add Caddy later.

What’s Next

  • Test OpenClaw with the Web UI — verify Claude responds, sandbox containers spin up/down correctly
  • Assess skills/tools — once the agent is behaviorally stable, we’ll evaluate which tools to enable
  • Monitoring — add openclaw to our observability stack (Prometheus node exporter, systemd service state)
  • Messaging channel — Slack or Discord integration is deferred until trust is established in the research phase
  • Caddy entryopenclaw.example.com with local_only if SSH tunnel becomes inconvenient for daily use

The foundation is solid. Time to start prompting.