Skip to content

Repository files navigation

Carapace

SHELL UP. LOCK DOWN.

Carapace — Webhook Armor for OpenClaw

Carapace is a hardened reverse proxy that sits in front of OpenClaw's webhook endpoints. It validates, rate-limits, and forwards only safe requests. The Gateway never touches the public internet — only Caddy is exposed.

If you expose OpenClaw webhooks to the internet, run them through Carapace.

How it works

Internet ──► Caddy (TLS, :443) ──► Carapace (Bun, :3000) ──► OpenClaw (:18789)

Carapace supports multi-upstream routing — run multiple isolated OpenClaw containers, each with its own token, config, and workspace. See Multi-Agent Setup.

The compose stack runs three services (or more with multi-agent):

Service Image Role
caddy caddy:2-alpine TLS termination, security headers, reverse proxy
carapace therealstein/carapace:latest Auth, rate limiting, body validation, token-based routing
openclaw-primary alpine/openclaw Primary gateway backend
openclaw-secondary alpine/openclaw Secondary gateway backend (optional)

Install (recommended)

All images are pulled from Docker Hub — no local build required.

cp .env.example .env
# Edit .env — set tokens (see Configuration)

docker compose up -d

That's it. Caddy auto-provisions TLS via Let's Encrypt when you set DOMAIN.

Local dev (no Docker)

bun install
bun run src/index.ts

OpenClaw (Docker)

Docker is optional. Use it only if you want a containerized gateway or to validate the Docker flow.

  • Yes — you want an isolated, throwaway gateway environment or to run OpenClaw on a host without local installs.
  • No — you're running on your own machine and just want the fastest dev loop. Use the local dev flow.

Sandboxing note: agent sandboxing uses Docker too, but it does not require the full gateway to run in Docker. See OpenClaw Sandboxing.

Requirements

  • Docker Desktop (or Docker Engine) + Docker Compose v2
  • At least 512 MB RAM available for the stack

Control UI (dashboard)

The OpenClaw Control UI is available via SSH tunnel — it is not exposed to the internet.

ssh -N -L 18789:127.0.0.1:18789 user@your-server

Then open http://localhost:18789/ and paste the gateway token to connect.

Need the token again?

docker compose exec openclaw-primary node dist/index.js dashboard --no-open

Channel setup (optional)

# WhatsApp (QR)
docker compose exec openclaw-primary node dist/index.js channels login

# Telegram
docker compose exec openclaw-primary node dist/index.js channels add --channel telegram --token "<token>"

# Discord
docker compose exec openclaw-primary node dist/index.js channels add --channel discord --token "<token>"

Health check

docker compose exec openclaw-primary node dist/index.js health --token "$OPENCLAW_GATEWAY_TOKEN"

Persistent data

Config and workspace live in Docker volumes. Each instance has its own volumes. Inspect:

docker volume inspect carapace_openclaw_primary_config

Permissions + EACCES

The alpine/openclaw image runs as node (uid 1000). If you see permission errors on /home/node/.openclaw, make sure your host bind mounts are owned by uid 1000:

sudo chown -R 1000:1000 /path/to/openclaw-config /path/to/openclaw-workspace

Configuration

Multi-route (recommended)

Each route maps a Bearer token to an OpenClaw upstream. Carapace routes requests based on which token matches.

Variable Required Description
ROUTE_<NAME>_TOKEN Yes Bearer token for this route
ROUTE_<NAME>_UPSTREAM Yes OpenClaw upstream URL for this route
ROUTE_<NAME>_HOOKS_TOKEN No Token forwarded to this OpenClaw instance

Example:

ROUTE_PRIMARY_TOKEN=abc123
ROUTE_PRIMARY_UPSTREAM=http://openclaw-primary:18789
ROUTE_PRIMARY_HOOKS_TOKEN=xyz789

ROUTE_SECONDARY_TOKEN=def456
ROUTE_SECONDARY_UPSTREAM=http://openclaw-secondary:18790
ROUTE_SECONDARY_HOOKS_TOKEN=uvw321

Route names must be alphanumeric (no underscores). <NAME> is case-insensitive in matching.

Legacy single-route (backward compatible)

If no ROUTE_* vars are set, Carapace falls back to single-route mode:

Variable Required Default Description
CARAPACE_TOKEN Yes* Bearer token for webhook auth
OPENCLAW_UPSTREAM No http://127.0.0.1:18789 OpenClaw upstream URL
OPENCLAW_HOOKS_TOKEN No Token forwarded to OpenClaw

Global settings

Variable Default Description
DOMAIN localhost Domain for Caddy TLS
CARAPACE_HMAC_SECRET HMAC-SHA256 secret (global, not per-route)
RATE_LIMIT_MAX 30 Max requests per window
RATE_LIMIT_WINDOW_MS 60000 Rate limit window in ms
MAX_BODY_SIZE 65536 Max request body size in bytes
PROXY_TIMEOUT_MS 30000 Upstream request timeout in ms
LOG_LEVEL info Log level: debug, info, warn, error

* At least one of CARAPACE_TOKEN (or ROUTE_*_TOKEN) or CARAPACE_HMAC_SECRET must be set.

Auth modes

  • Token only — set CARAPACE_TOKEN or ROUTE_*_TOKEN; requests need Authorization: Bearer <token>.
  • HMAC only — set CARAPACE_HMAC_SECRET; requests need x-openclaw-hmac-sha256 header.
  • Both — set both; requests must pass both checks.

Endpoints

Method Path Description
GET /health Health check (no auth)
POST /hooks/wake Wake hook — requires text field
POST /hooks/agent Agent hook — requires message field
POST /hooks/:name Mapped hooks — pass-through with size limit

Deployment + DNS

Caddy auto-provisions TLS via Let's Encrypt. You just need a domain with a DNS record pointing to your server.

Domain options

Option Cost Notes
Cloudflare Tunnel Free No ports to open, no static IP needed. Outbound tunnel to Cloudflare's edge.
DuckDNS Free Dynamic DNS subdomain (yourname.duckdns.org). Good for home servers.
Tailscale Funnel Free Exposes via *.ts.net. Best for private/dev use.
Porkbun / Cloudflare Registrar ~$2–10/yr Cheap domains (.xyz, .dev, .app). Cloudflare sells at cost.

Own domain + DNS (production)

  1. Buy a domain (e.g. example.xyz — Porkbun or Cloudflare Registrar).

  2. Point nameservers to Cloudflare (free plan) or your registrar's DNS.

  3. Add an A record:

    Type  Name  Content       TTL
    A     @     203.0.113.42  Auto
    
  4. Set DOMAIN=example.xyz in .env.

  5. Open ports 80 + 443 on your server.

  6. docker compose up -d — Caddy obtains and renews certs automatically.

Cloudflare Tunnel (free, no ports)

Use this if you can't open ports or don't have a static IP.

# Install cloudflared
curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
  -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared

# Auth + create tunnel
cloudflared tunnel login
cloudflared tunnel create carapace

Configure ~/.cloudflared/config.yml:

tunnel: <TUNNEL_ID>
credentials-file: ~/.cloudflared/<TUNNEL_ID>.json

ingress:
  - hostname: example.xyz
    service: http://localhost:443
  - service: http_status:404

Add a CNAME in Cloudflare DNS (@ → <TUNNEL_ID>.cfargotunnel.com), then:

cloudflared tunnel run carapace

DuckDNS (free dynamic DNS)

  1. Sign up at duckdns.org — create a subdomain.

  2. Auto-refresh your IP (crontab):

    echo url="https://www.duckdns.org/update?domains=YOURSUBDOMAIN&token=YOUR_DUCKDNS_TOKEN&ip=" | curl -k -o /dev/null -s -K -
  3. Set DOMAIN=yoursubdomain.duckdns.org in .env.

  4. Open ports 80 + 443 on your router/firewall.

  5. docker compose up -d

Firewall checklist

Port Protocol Required by
80 TCP Caddy — Let's Encrypt ACME HTTP-01 challenge
443 TCP + UDP Caddy — HTTPS + HTTP/3 QUIC

Ports 3000 (Carapace), 18789 and 18790 (OpenClaw) stay on the internal Docker network. Do not expose them.

Tip: Lock down your firewall to only allow 22 (SSH), 80, and 443 inbound. Drop everything else.

Migrating from a local OpenClaw install

If you already run OpenClaw locally (not in Docker) and want to move it into the Carapace stack, paste this prompt into Claude Code:

SSH into <user>@<host> and migrate the existing local OpenClaw install into the
Carapace Docker stack:

1. Find the local OpenClaw data directory (usually ~/.openclaw)
2. Stop all running openclaw processes (kill -SIGTERM, then kill -9 if needed)
3. Create /opt/carapace with docker-compose.yml, Caddyfile, and .env from the repo
4. Set DOMAIN=<your-domain> in .env
5. Generate a CARAPACE_TOKEN and extract the gateway token from openclaw.json
6. docker compose create openclaw-primary (to initialize volumes)
7. Copy local .openclaw/* into the openclaw_primary_config volume, workspace into openclaw_primary_workspace volume
8. Replace all hardcoded paths (e.g. /root/.openclaw) with /home/node/.openclaw in
   all .json and .jsonl files (find + sed -i)
9. Fix openclaw.json gateway config for Docker:
   - Set gateway.bind to "lan" (so Carapace can reach it over the Docker network)
   - Remove gateway.mode (local-only field, invalid in Docker)
   - Set gateway.controlUi.allowedOrigins to ["http://localhost:18789", "http://127.0.0.1:18789"]
     (required when bind is non-loopback; allows SSH tunnel access to the dashboard)
   - Update agents.defaults.workspace to /home/node/.openclaw/workspace
10. chown -R 1000:1000 on both volumes (container runs as node/uid 1000)
11. docker compose up -d
12. Uninstall the host-level OpenClaw (npm uninstall -g openclaw), kill any remaining
    host processes, but keep ~/.openclaw as a backup
13. Verify all three services are healthy, TLS cert is obtained, and test a webhook

Multi-Agent Setup

Run multiple isolated OpenClaw instances behind a single Carapace proxy. Each instance gets its own container, config, workspace, and volumes — fully isolated.

                          ┌─► openclaw-primary   (:18789)
Internet ──► Caddy ──► Carapace
                          └─► openclaw-secondary (:18790)

1. Generate tokens

Each route needs three tokens: a Carapace bearer token, an OpenClaw hooks token, and a gateway token.

# Generate tokens (one set per route)
openssl rand -hex 24   # ROUTE_PRIMARY_TOKEN
openssl rand -hex 24   # ROUTE_PRIMARY_HOOKS_TOKEN
openssl rand -hex 24   # OPENCLAW_PRIMARY_GATEWAY_TOKEN
openssl rand -hex 24   # ROUTE_SECONDARY_TOKEN
openssl rand -hex 24   # ROUTE_SECONDARY_HOOKS_TOKEN
openssl rand -hex 24   # OPENCLAW_SECONDARY_GATEWAY_TOKEN

2. Configure .env

DOMAIN=example.xyz

ROUTE_PRIMARY_TOKEN=<generated>
ROUTE_PRIMARY_UPSTREAM=http://openclaw-primary:18789
ROUTE_PRIMARY_HOOKS_TOKEN=<generated>

ROUTE_SECONDARY_TOKEN=<generated>
ROUTE_SECONDARY_UPSTREAM=http://openclaw-secondary:18790
ROUTE_SECONDARY_HOOKS_TOKEN=<generated>

OPENCLAW_PRIMARY_GATEWAY_TOKEN=<generated>
OPENCLAW_SECONDARY_GATEWAY_TOKEN=<generated>

3. Start the stack

docker compose up -d

4. Configure each OpenClaw instance

Each instance needs its gateway token, hooks token, and bind address configured in its openclaw.json:

# Primary — port 18789
docker exec <primary-container> node -e "
  const fs = require('fs');
  const c = JSON.parse(fs.readFileSync('/home/node/.openclaw/openclaw.json','utf8'));
  c.gateway.port = 18789;
  c.gateway.bind = 'lan';
  c.gateway.auth.token = '<OPENCLAW_PRIMARY_GATEWAY_TOKEN>';
  c.hooks = { enabled: true, token: '<ROUTE_PRIMARY_HOOKS_TOKEN>' };
  fs.writeFileSync('/home/node/.openclaw/openclaw.json', JSON.stringify(c, null, 2));
"

# Secondary — port 18790
docker exec <secondary-container> node -e "
  const fs = require('fs');
  const c = JSON.parse(fs.readFileSync('/home/node/.openclaw/openclaw.json','utf8'));
  c.gateway.port = 18790;
  c.gateway.bind = 'lan';
  c.gateway.auth.token = '<OPENCLAW_SECONDARY_GATEWAY_TOKEN>';
  c.hooks = { enabled: true, token: '<ROUTE_SECONDARY_HOOKS_TOKEN>' };
  fs.writeFileSync('/home/node/.openclaw/openclaw.json', JSON.stringify(c, null, 2));
"

Important: The hooks token must differ from the gateway auth token — OpenClaw rejects matching tokens.

5. Verify routing

# Primary route
curl -X POST https://example.xyz/hooks/wake \
  -H 'Authorization: Bearer <ROUTE_PRIMARY_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"text":"test primary"}'

# Secondary route
curl -X POST https://example.xyz/hooks/wake \
  -H 'Authorization: Bearer <ROUTE_SECONDARY_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"text":"test secondary"}'

Dashboard access

Each instance binds to a different localhost port for SSH tunnel access:

# Primary dashboard
ssh -N -L 18789:127.0.0.1:18789 user@your-server

# Secondary dashboard
ssh -N -L 18790:127.0.0.1:18790 user@your-server

Adding more instances

Add another ROUTE_<NAME>_* block to .env and a matching openclaw-<name> service to docker-compose.yml. Each instance needs a unique port.

Security

  • Bearer token + optional HMAC-SHA256 signature verification.
  • Rejects ?token= query params (400) — tokens belong in headers.
  • Sliding window rate limiting with progressive IP lockout (3 auth failures → 5 min block).
  • Body size limits (413 on oversized payloads).
  • Schema validation per endpoint.
  • Never logs request bodies or tokens.
  • Runs as non-root user in Docker with read-only filesystem.
  • Only Caddy is exposed to the internet.

About

Hardened reverse proxy for OpenClaw webhook endpoints

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages