Expose OpenWA with Cloudflare Tunnel
Cloudflare Tunnel (cloudflared) connects a public hostname on Cloudflare's edge to an OpenWA
instance that is not otherwise reachable from the internet — behind NAT, on a home network,
or in a private subnet. The tunnel client runs next to OpenWA and opens a long-lived outbound
connection to Cloudflare, so no inbound ports have to be opened and no public IP is required.
Cloudflare terminates TLS at the edge and forwards plain HTTP to OpenWA on 127.0.0.1:2785.
Because OpenWA already treats its single port as plain HTTP behind a TLS-terminating reverse proxy (see Deployment), a Cloudflare Tunnel drops in as a drop-in replacement for nginx, Caddy, or Traefik — including for the Socket.IO real-time channel and the bundled dashboard.
- An OpenWA instance running on the host (or in a container) reachable at
http://127.0.0.1:2785. See Deployment if you have not come this far. - A Cloudflare account. The free plan is sufficient for testing and many production setups; see Free-plan limits for where it bites.
- A domain name on Cloudflare DNS, and the
cloudflaredclient installed on the host where OpenWA runs.
How it fits
OpenWA serves the REST API, the dashboard, Swagger, and the Socket.IO channel on one port
(2785). A Cloudflare Tunnel routes a single public hostname to that one origin over HTTP,
forwards WebSocket upgrades natively, and terminates TLS on Cloudflare's edge.
No inbound ports need to be opened on the host. The only persistent connection is the one
cloudflared opens out to Cloudflare.
OpenWA environment
Set these variables on the OpenWA process before exposing it through a tunnel. They are covered in detail in Deployment and Configuration; reproduced here because they matter specifically for a tunneled deployment.
| Variable | Purpose | Why it matters behind a tunnel |
|---|---|---|
TRUSTED_PROXIES | IPs/CIDRs whose X-Forwarded-For header to honor | Without this, OpenWA reads the tunnel daemon's IP as the client and rate limiting / per-key IP allowlists misbehave. Set to the daemon's address (for example 127.0.0.1 on the host, or the daemon container's subnet in Docker). |
BASE_URL | Public URL the API advertises | Set to your public hostname (for example https://wa.example.com) so banners and any URL-building code reference the public origin. |
CORS_ORIGINS | Allowed browser origins for the dashboard | The wildcard * is refined in production. Set explicit origins, for example https://wa.example.com. |
CSP_UPGRADE_INSECURE_REQUESTS | Forces HTTPS in the dashboard's CSP | Leave the production default (true) — Cloudflare's edge serves HTTPS, so the upgrade is a no-op. |
ENABLE_SWAGGER | Interactive docs at /api/docs | Set to false on internet-facing deployments to reduce reconnaissance surface. |
API_KEY_PEPPER | HMAC pepper for stored API-key hashes | Recommended defense in depth on any internet-facing deployment. |
Exposing OpenWA publicly exposes both the API and the bundled dashboard on the same host. OpenWA authenticates the dashboard by API key, but you should still put a second factor in front of the dashboard surface. See Protect the dashboard.
Quick tunnel (for testing)
A quick tunnel gives you a throwaway *.trycloudflare.com URL with zero configuration — perfect
for a one-off test. No Cloudflare login or DNS record is required.
# From the same host where OpenWA is running on :2785
cloudflared tunnel --url http://localhost:2785
cloudflared prints a random https://<random>.trycloudflare.com URL. Open it in a browser and
the dashboard should load over HTTPS; the Socket.IO real-time channel should connect within a
few seconds. The URL is valid only as long as the process runs and is not suitable for
production.
Named tunnel (production)
A named tunnel binds a stable hostname to your OpenWA instance. You create it once, route DNS to it, and then run it.
On the host
# 1. Authenticate once
cloudflared tunnel login
# 2. Create the tunnel
cloudflared tunnel create openwa
# 3. Route a hostname on Cloudflare DNS to the tunnel
cloudflared tunnel route dns openwa wa.example.com
Then point the tunnel at OpenWA with a config file at ~/.cloudflared/config.yml:
tunnel: <TUNNEL_UUID_FROM_CREATE>
credentials-file: /root/.cloudflared/<TUNNEL_UUID>.json
ingress:
- hostname: wa.example.com
service: http://localhost:2785
- service: http_status:404
Run the tunnel:
cloudflared tunnel run openwa
Install it as a system service so it survives reboots:
sudo cloudflared service install
As a Docker sidecar
If OpenWA runs in Docker, run cloudflared as a sidecar on the same Compose network. Create the
tunnel and its DNS route from your host first (the steps above), then mount the credentials file
into the sidecar container.
services:
openwa:
image: ghcr.io/rmyndharis/openwa:latest
expose:
- "2785" # internal only — no host port mapping needed
environment:
- TRUSTED_PROXIES=172.16.0.0/12 # the cloudflared container's subnet
- BASE_URL=https://wa.example.com
- CORS_ORIGINS=https://wa.example.com
- ENABLE_SWAGGER=false
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=<token-from-cloudflare-zero-trust-dashboard>
depends_on:
- openwa
The cleanest way to run cloudflared in a container is to create the tunnel from the
Cloudflare Zero Trust dashboard (Networks → Tunnels → Create a tunnel), copy the resulting
TUNNEL_TOKEN, and point the container at it. The dashboard writes the tunnel's public hostname
→ service mapping for you, so no config.yml mount is required.
For the OpenWA container, prefer expose over ports. Mapping the port to the host is harmless
on its own, but if you ever stack another reverse proxy in front (Traefik, Coolify, a load
balancer), the double hop exhausts the long-lived Socket.IO connections and the dashboard starts
returning 504s. See Deployment for the full Traefik-equivalent
note.
Free-plan limits
The Cloudflare free plan is enough for many single-tenant deployments, but it imposes two limits that interact with a WhatsApp API gateway:
| Limit | Free plan | Effect on OpenWA |
|---|---|---|
| Request body size | 100 MB | Media sends carry base64 in the JSON body, so a 75 MB video can exceed the limit after encoding overhead. Large media uploads are the most common breakage. |
| Per-request timeout | 100 seconds | Long synchronous operations (a slow bulk send, a session start that waits for QR readiness) can be cut off mid-flight. |
| No SLA on the tunnel daemon | Best-effort | Adequate for self-hosting; for higher availability use multiple cloudflared replicas on a paid plan. |
If you hit these, either move the affected workload to a paid Cloudflare plan or keep that specific operation on a non-tunneled path (for example, a private network route for media uploads).
A freshly provisioned hostname inherits Cloudflare's proxy (orange-cloud) defaults. Some of those defaults disrupt programmatic API traffic:
- Bot Fight Mode and Browser Integrity Check can challenge or block API clients. Disable them for the OpenWA hostname in Security → Bots and Security → Settings.
- Rate limiting rules under Security → WAF → Rate limiting rules can collide with OpenWA's own throttler. Either tune them or disable them for the hostname.
- Caching can cache a
GETthat should be live (for example,/api/sessions/{id}/qr). Either set the hostname to bypass cache, or restrict caching to known-static paths.
Protect the dashboard
On a public deployment, OpenWA's API-key auth is the only thing between the dashboard and the internet. Add a second factor with Cloudflare Access (part of Zero Trust, free up to 50 users):
- In the Cloudflare Zero Trust dashboard, open Access → Applications → Add an application.
- Create a Self-hosted application for the OpenWA hostname (
wa.example.com). - Add a policy — for example, allow your email address or an identity provider group.
- Optionally create a second application scoped to a path like
wa.example.com/api/*with a different (or no) policy if the API must be reachable from arbitrary API clients that cannot complete an interactive login.
For machine-to-machine API access through an Access-protected hostname, use a service token
(Access → Service Auth → Service Tokens) and send the CF-Access-Client-Id /
CF-Access-Client-Secret headers alongside OpenWA's X-API-Key.
WebSocket and real-time notes
OpenWA's dashboard upgrades to a WebSocket on /socket.io/. Cloudflare Tunnels forward
WebSocket upgrades natively, so no special configuration is required. If the dashboard's status
indicator stays on "Connecting…" while the REST API works:
- Confirm
cloudflaredis running and the tunnel reports Healthy in the Zero Trust dashboard. - Confirm nothing else (another proxy, a WAF rule, an aggressive idle-timeout policy) sits between the edge and OpenWA — the dashboard's real-time channel is the most sensitive canary for a misconfigured proxy hop.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Client IP in audit logs is always the tunnel daemon | TRUSTED_PROXIES unset | Set it to the daemon's IP/subnet so X-Forwarded-For is honored. |
| Dashboard loads but status stuck on "Connecting…" | WebSocket upgrade blocked upstream | Verify the tunnel is healthy; remove any extra proxy hop; check Cloudflare WAF rules. |
413 Request Entity Too Large on media sends | Free-plan 100 MB body limit | Move media to a paid plan, or send it over a private network route that bypasses the edge. |
524 A Timeout Occurred on long operations | Free-plan 100-second per-request timeout | Restructure the call to be asynchronous (queue a job, poll a status endpoint), or move to a paid plan. |
| API clients intermittently challenged by a CAPTCHA | Bot Fight Mode on | Disable Bot Fight Mode for the OpenWA hostname. |
Dashboard works but GET /api/sessions/{id}/qr returns stale data | Cloudflare cached the response | Bypass cache for /api/*, or set the hostname to "DNS only" (grey-cloud) for testing. |
| Tunnel reconnects frequently | Host network flapping | Pin the tunnel to a wired connection; consider running two cloudflared replicas for redundancy. |
For app-level issues unrelated to the tunnel, see Troubleshooting & FAQ.
Next steps
- Deployment — the canonical production setup with a self-hosted reverse proxy.
- Configuration — the full environment-variable reference.
- Authentication — API keys, roles, and per-key IP allowlists.
- Webhooks — outbound delivery, which is unaffected by the inbound tunnel.