Skip to main content

Secure OpenClaw with a Caddy reverse proxy + HTTPS

· 8 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
OpenClaw+

In Article 1 we got OpenClaw running — but only over plain HTTP, with an allowInsecureAuth workaround. Here we put Caddy in front of it as a reverse proxy: real HTTPS, device-paired auth, and the gateway's raw ports closed so the proxy is the only way in. Every command and error below is from the actual deploy.

Reproducibility

Continues from Article 1 on the same WEC Instance — Ubuntu 22.04.5 LTS, OpenClaw 2026.6.8. We use Caddy 2 (caddy:2). Because this box has no public domain, we use Caddy's internal CA (tls internal) for self-signed HTTPS; the Going to production section shows the one-line swap to real Let's Encrypt certificates once you have a domain.


What you'll build

Caddy terminates HTTPS and reverse-proxies to the OpenClaw gateway over Docker's internal network. The gateway stops publishing its own ports — the only entry point becomes Caddy on :443.


Prerequisites

  • Article 1 completed — OpenClaw running in ~/openclaw
  • Shell access to the VM
  • A hostname for the gateway. With a public domain, use it (and get real Let's Encrypt certs). On a private box, we use openclaw.local + a hosts entry.

Step 1 — Confirm the starting point

cd ~/openclaw
docker compose ps

You should see openclaw-gateway Up (healthy), publishing 18789/18790/3978. That's our starting state: working, but HTTP-only.

docker compose ps showing the gateway running


Step 2 — Write the Caddyfile

Caddy's config: serve HTTPS and proxy everything to the gateway. tls internal uses Caddy's own local CA — no public domain needed.

cat > Caddyfile <<'EOF'
# Caddy selects its certificate by SNI (the hostname in the TLS handshake).
# A bare IP sends no SNI, so we use a hostname. With a public domain, put your
# domain here and drop `tls internal` for automatic Let's Encrypt.
openclaw.local {
tls internal
reverse_proxy openclaw-gateway:18789
}
EOF

The proxy target is openclaw-gateway:18789 — Caddy reaches the gateway by its Compose service name over the shared Docker network.


Step 3 — Add Caddy to Compose

docker compose auto-merges a docker-compose.override.yml, so we add a caddy service without touching OpenClaw's official compose file:

cat > docker-compose.override.yml <<'EOF'
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config

volumes:
caddy_data:
caddy_config:
EOF

caddy_data persists the local CA + certs across restarts.


Step 4 — Start Caddy

docker compose up -d
docker compose ps

Caddy comes up alongside the gateway with 443 published, and generates its internal CA + a certificate on first run.

Caddy container up with 443 published

note

docker compose up -d also starts the one-shot openclaw-cli helper; it idling here is harmless — we invoke it with docker compose run --rm when needed.


Step 5 — Verify HTTPS

Hit the gateway's health endpoint through Caddy. --resolve makes curl send the hostname (SNI) while pointing at the box:

curl -k --resolve openclaw.local:443:127.0.0.1 https://openclaw.local/healthz
# {"ok":true,"status":"live"}

healthz returning over HTTPS via Caddy

Hit a tlsv1 alert internal error?

That happens if you used a bare IP as the site address — see Troubleshooting #1. Use a hostname.


Step 6 — Re-secure the gateway

Now traffic arrives over https://openclaw.local. Two config changes:

  1. Add the HTTPS origin to allowedOrigins (the Control UI rejects unknown origins).
  2. Turn allowInsecureAuth back off — HTTPS is a secure context, so the Article 1 workaround is no longer needed.
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js config set --batch-json '[{"path":"gateway.controlUi.allowedOrigins","value":["https://openclaw.local"]},{"path":"gateway.controlUi.allowInsecureAuth","value":false}]'

docker compose restart openclaw-gateway

Step 7 — Pair your browser

Open https://openclaw.local/ in your browser (on a private box, first map 10.80.4.212 openclaw.local in your machine's hosts file — /etc/hosts on macOS/Linux, or C:\Windows\System32\drivers\etc\hosts edited as Administrator on Windows).

You'll hit a certificate warning first"Your connection is not private" (NET::ERR_CERT_AUTHORITY_INVALID). This is expected with tls internal: the certificate is signed by Caddy's local CA, which your operating system doesn't trust. Click Advanced → Proceed to openclaw.local (unsafe) to continue. (With a real domain + Let's Encrypt, the certificate is publicly trusted and this warning never appears — see Going to production.)

Browser cert warning — click Advanced, then Proceed

Now the page loads — but with allowInsecureAuth off, the gateway requires device pairing before this browser can use the Control UI. That's the secure flow doing its job. The screen shows a request id; approve it on the host:

docker compose run --rm openclaw-cli devices approve <request-id-from-the-screen>

Device pairing required screen

Back in the browser, click Connect again — it pairs and loads the dashboard.


Step 8 — Lock down direct access (the real hardening)

The gateway still publishes 18789 to the host, so http://<vm-ip>:18789 is still reachable in plain HTTP, bypassing Caddy. Close it: stop publishing the gateway's ports so Caddy on :443 is the only door. Caddy still reaches it over the internal network.

cat > docker-compose.override.yml <<'EOF'
services:
openclaw-gateway:
# Unpublish the gateway's host ports — Caddy reaches it internally, so the
# only entry point is HTTPS on :443.
ports: !reset []

caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config

volumes:
caddy_data:
caddy_config:
EOF
docker compose up -d

ports: !reset [] clears the ports the base compose published.


Step 9 — Prove it's locked

curl -s -m 5 http://<vm-ip>:18789/healthz ; echo " <- direct HTTP (should FAIL)"
curl -sk --resolve openclaw.local:443:127.0.0.1 https://openclaw.local/healthz ; echo " <- via Caddy (should work)"

The first returns nothing (connection refused) — the insecure door is gone. The second still returns {"ok":true,"status":"live"}.

Before/after: direct port refused, HTTPS works

Your OpenClaw is now reachable only over HTTPS, device-paired.

OpenClaw Control UI over HTTPS


Troubleshooting (real errors)

1. tlsv1 alert internal error

Hit when the Caddyfile site address was a bare IP (https://10.80.4.212):

curl: (35) error:0A000438:SSL routines::tlsv1 alert internal error

Caddy's logs showed the cert was obtained fine. Cause: TLS picks a certificate by SNI (the hostname in the handshake), but connections to a bare IP send no SNI — so Caddy can't match a cert and aborts. Fix: use a hostname site address (Step 2). A reader with a real domain never hits this — the domain is the SNI.

2. Device pairing required

On the first HTTPS connect after turning off allowInsecureAuth:

Device pairing required — This browser needs one-time approval from the Gateway
host before it can use the Control UI.

Cause: this is the correct secure flow — with insecure auth off, each new browser must be approved. Fix: docker compose run --rm openclaw-cli devices approve <request-id>. (The CLI may log scope upgrade pending approval … using local fallback but still completes the approval.)

3. NET::ERR_CERT_AUTHORITY_INVALID

Expected with tls internal — the cert is signed by Caddy's local CA, which your OS doesn't trust. Click through on a private box. With a public domain + Let's Encrypt (below), the cert is trusted and there's no warning.


Going to production

With a real domain, swap two lines in the Caddyfile — point it at your domain and drop tls internal:

- openclaw.local {
- tls internal
+ openclaw.example.com {
reverse_proxy openclaw-gateway:18789
}

Then point the domain's DNS A record at the VM's public IP and open 80 + 443 to the internet. Caddy automatically provisions and renews a Let's Encrypt certificate — trusted, no warning, no --resolve or /etc/hosts needed. Update allowedOrigins to https://openclaw.example.com.


Security notes

  • Single entry point — only Caddy's :443 is exposed; the gateway has no host ports (Step 8).
  • Device pairing — every browser needs one-time approval (openclaw devices).
  • Origin allowlist — the Control UI only accepts https://openclaw.local.
  • Rotate secrets — regenerate OPENCLAW_GATEWAY_TOKEN if it has ever been shown (screenshots, screen-shares).

What's next

You've finished Part 2 of the Self-hosting OpenClaw series. Next:

Companion files (Caddyfile, docker-compose.override.yml) live in the WiLine manifests repo (link coming with the repo).


Teardown

To remove just Caddy and reopen the direct ports, delete docker-compose.override.yml and Caddyfile, then docker compose up -d. To stop everything:

docker compose down