SHELL UP. LOCK DOWN.
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.
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) |
All images are pulled from Docker Hub — no local build required.
cp .env.example .env
# Edit .env — set tokens (see Configuration)
docker compose up -dThat's it. Caddy auto-provisions TLS via Let's Encrypt when you set DOMAIN.
bun install
bun run src/index.tsDocker 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.
- Docker Desktop (or Docker Engine) + Docker Compose v2
- At least 512 MB RAM available for the stack
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-serverThen 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# 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>"docker compose exec openclaw-primary node dist/index.js health --token "$OPENCLAW_GATEWAY_TOKEN"Config and workspace live in Docker volumes. Each instance has its own volumes. Inspect:
docker volume inspect carapace_openclaw_primary_configThe 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-workspaceEach 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=uvw321Route names must be alphanumeric (no underscores). <NAME> is case-insensitive in matching.
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 |
| 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.
- Token only — set
CARAPACE_TOKENorROUTE_*_TOKEN; requests needAuthorization: Bearer <token>. - HMAC only — set
CARAPACE_HMAC_SECRET; requests needx-openclaw-hmac-sha256header. - Both — set both; requests must pass both checks.
| 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 |
Caddy auto-provisions TLS via Let's Encrypt. You just need a domain with a DNS record pointing to your server.
| 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. |
-
Buy a domain (e.g.
example.xyz— Porkbun or Cloudflare Registrar). -
Point nameservers to Cloudflare (free plan) or your registrar's DNS.
-
Add an
Arecord:Type Name Content TTL A @ 203.0.113.42 Auto -
Set
DOMAIN=example.xyzin.env. -
Open ports 80 + 443 on your server.
-
docker compose up -d— Caddy obtains and renews certs automatically.
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 carapaceConfigure ~/.cloudflared/config.yml:
tunnel: <TUNNEL_ID>
credentials-file: ~/.cloudflared/<TUNNEL_ID>.json
ingress:
- hostname: example.xyz
service: http://localhost:443
- service: http_status:404Add a CNAME in Cloudflare DNS (@ → <TUNNEL_ID>.cfargotunnel.com), then:
cloudflared tunnel run carapace-
Sign up at duckdns.org — create a subdomain.
-
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 -
-
Set
DOMAIN=yoursubdomain.duckdns.orgin.env. -
Open ports 80 + 443 on your router/firewall.
-
docker compose up -d
| 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.
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
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)
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_TOKENDOMAIN=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>docker compose up -dEach 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.
# 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"}'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-serverAdd another ROUTE_<NAME>_* block to .env and a matching openclaw-<name> service to docker-compose.yml. Each instance needs a unique port.
- 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.
