Reverse Tunnel for Databases
Connect a database behind a firewall (or self-hosted on a machine with no public IP) to HostWebhook without opening ANY inbound port. A small agent (CLI or Docker) on your network opens ONE outbound WebSocket to us, and Vector Store / AI Memory / DB Action queries ride that connection — your DB never touches the public internet.
When to use this
- Zero inbound ports — your network admins won't let you open Mongo/Postgres, even with IP allowlisting.
- Self-hosted DB on a private network — your cluster isn't on Atlas/Neon/Supabase and has no public hostname.
- Zero IPs to allowlist — works behind any firewall that permits outbound HTTPS (~all of them).
How it works
HW api ◀──TCP──▶ Local forwarder (127.0.0.1:N)
│ Socket.IO over HTTPS (multiplexed streams)
▼
Agent (hostwh expose-tcp on your network)
│ TCP
▼
Your local MongoDB / PostgreSQLEach client query on our side becomes a multiplexed stream over a single persistent WebSocket. Many concurrent queries fan out as independent streams without re-handshaking.
Step 1 — Mint an API key
Long-lived API keys (the GitHub PAT model) are how Docker / CI / cron jobs auth without a browser. Create one in the dashboard or via the CLI.
hwk_. The older hwt_ family was retired — those rows no longer authenticate, so a token minted before August 2026 has to be replaced. There used to be two machine credentials running in parallel, with two vocabularies and two lifecycles; the split was the root of several security holes, including a key that could mint a token with scopes it did not hold itself. One credential survives: the API key.From the CLI
hostwh login # one-time browser auth
hostwh tokens create --name=prod-db --scope=tunnel:write
# → hwk_AbCd1234… (printed once, copy this)The command is still called tokens on purpose — renaming it would break every script that already calls it. What changed is the prefix of what it hands back.
From the dashboard
Settings → Tunnels → Create agent key, which opens the Create API Key dialog with tunnel:write already ticked. Name it, set the expiry (90 days by default, 365 max), and click Create. Copy the key immediately — it's shown once, then only the hash lives in our DB. The same key can be minted from Settings → API Keys by ticking Tunnels → Write: there is one kind of credential, and the scopes decide what it may do.
tunnel:write and nothing else. It costs nothing and it buys two things: revoking a compromised agent does not take the other ones down with it, and Last used in the key list tells you which agent is actually alive. A key that runs three agents can only be revoked three times over.Step 2 — Run the agent
On a machine with network access to your DB, run one of:
CLI
# MongoDB on localhost:27017
hostwh expose-tcp --target=localhost:27017 --id=prod-mongo --token=hwk_AbCd1234…
# PostgreSQL on localhost:5432
hostwh expose-tcp --target=localhost:5432 --id=prod-pg --token=hwk_AbCd1234…
# Or with the token in an env var (cleaner in shell history)
HOSTWH_TOKEN=hwk_AbCd1234… hostwh expose-tcp --target=localhost:5432 --id=prod-pg--id. It is the name you reuse, and without it the agent prints (ephemeral) and means it: every start mints a brand new tunnel with a brand new id. Two things follow, and neither says so out loud.- Your credential stops working on the next restart. It stores the
tunnelIdit was bound to, and that tunnel no longer exists — queries hang and time out with “Tunnel offline” while an agent sits there connected, on a different id. - Each restart eats a slot. Active tunnels are capped by plan — 1 on Free, 10 on Pro — so the second ephemeral run already answers “Free plan allows 1 active tunnel. Delete an existing one or upgrade.” The orphans are deleted in Settings → Tunnels.
With --id, restarting re-claims the same tunnel: same id, same credential, no new slot. Use a name that says what it reaches (prod-mongo), not what machine it runs on — if you move the agent, the name should still be true. On Docker it is HOSTWH_LABEL.
Docker
# MongoDB
docker run -d --restart=unless-stopped --name hw-mongo-tunnel \
-e HOSTWH_TOKEN=hwk_AbCd1234… \
-e HOSTWH_TARGET=localhost:27017 \
-e HOSTWH_LABEL=prod-mongo \
--network=host \
ghcr.io/hostwebhook/agent:latest
# PostgreSQL
docker run -d --restart=unless-stopped --name hw-pg-tunnel \
-e HOSTWH_TOKEN=hwk_AbCd1234… \
-e HOSTWH_TARGET=localhost:5432 \
-e HOSTWH_LABEL=prod-pg \
--network=host \
ghcr.io/hostwebhook/agent:latestlocalhost. On Docker Desktop (Windows / Mac) use HOSTWH_TARGET=host.docker.internal:<port> instead — that DNS alias resolves to your host machine. If your DB runs in another container, drop --network=host, put both on the same docker network, and set HOSTWH_TARGET=<service>:<port>.docker-compose snippet
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# ... your existing config
hw-agent:
image: ghcr.io/hostwebhook/agent:latest
restart: unless-stopped
environment:
HOSTWH_TOKEN: ${HOSTWH_TOKEN} # from .env
HOSTWH_TARGET: postgres:5432 # service name + port
HOSTWH_LABEL: prod-pg
depends_on:
- postgresVerify the tunnel is online: Settings → Tunnels should show your tunnel with a TCP badge and a green Online indicator.
Bringing it back later
Stop the agent — or reboot the machine it runs on — and the tunnel goes Offline. Nothing is lost: the tunnel, its id and its target all survive, and the credential bound to it still points at the same place. The only missing piece is a running agent.
You do not have to remember the target. The server already has it, so the id is all you type:
hostwh tunnels list
# STATUS ID TYPE TARGET LABEL LAST SEEN
# offline 15cd1479f0 tcp localhost:27017 prod-mongo 23 h ago
hostwh tunnels start 15cd1479f0Needs hostwh 1.2.0 or newer — hostwh upgrade. On older versions, repeat the whole expose-tcp line with the same --id, which re-claims the same tunnel. If the agent runs in Docker it is docker start hw-mongo-tunnel — and note that --restart=unless-stopped will not bring back a container you stopped by hand.
hostwh tunnels start refuses when the tunnel is already online, which is that check doing its job.Step 3 — Bind the tunnel to a credential
Go to Settings → Credentials → New Credential, pick MongoDB or PostgreSQL, and fill in the form:
- Connection string — point at the local target the agent forwards to (NOT a public URL):
# MongoDB mongodb://user:pass@localhost:27017/your_database # PostgreSQL postgres://user:pass@localhost:5432/your_databaseAuth is still your responsibility. The tunnel only carries bytes — your DB's username/password gates access.
- Database — pre-filled from the URI path, and on PostgreSQL it is not decoration: the value in this field is the database the query actually runs against. Postgres picks its database at connect time, in the URI path, so we rewrite the URI with whatever this field says (keeping scheme, credentials, host and query string). If it disagrees with the path in your connection string, this field wins.
- SSL mode (Postgres only) — set to Off. The tunnel already encrypts the wire, and the local forwarder is plaintext on purpose. Don't add
?sslmode=requirehere or the driver will try to negotiate TLS against the local listener and fail. - Reverse tunnel — pick the TCP tunnel you registered in Step 2. The dropdown shows online status with a live dot.
Save. The credential's metadata now carries tunnelId=... — the SSRF guard that normally blocks localhost connection strings makes an exception for tunneled credentials.
Step 4 — Use it
Create a Vector Store with the matching backend (MongoDB Atlas — your own / PostgreSQL — pgvector) and pick this credential, OR set it as the AI Node Memory backend (External MongoDB / External PostgreSQL). Ingest + query work identically to the direct flow — bytes just take a different path.
Latency
The reverse tunnel adds:
- +5–15ms for the WebSocket round-trip when the agent and our gateway are in the same region (e.g. both us-east).
- +150–250ms when they're cross-continental (agent in Singapore, gateway in us-east). Multi-region tunnel servers are on the roadmap.
- +0ms agent ↔ DB — that hop is your local network, identical to a direct connection from your own app.
Realistic total overhead: +30–50ms per query with the agent in the same region. Vector search queries typically take 100–500ms total, so the tunnel adds <10% to end-to-end latency.
Limits
- Plan-gated active TCP tunnels per organization. Free: 1, Pro: 10, Enterprise: unlimited. Each holds a small connection pool in our process; the cap bounds peak memory.
- 1 target per tunnel. If you have 3 DBs to expose (e.g. mongo + postgres + a second postgres), run 3 tunnels (different
--idlabels). - Single-region tunnel servers (v1). Cross-pacific traffic pays a real latency penalty until we deploy to other regions.
Troubleshooting
“Tunnel registration rejected: Forbidden”
Either the token belongs to a different org, or it was created without the tunnel:write scope. Re-mint with --scope=tunnel:write.
“Another listener is registered on this tunnel”
A tunnel has one listening seat, and it belongs to whoever holds it. A different principal — another user, or a key belonging to someone else in the org — cannot take it over while you are connected. Reclaiming your own seat always works: reconnecting, restarting the agent, or the same socket re-registering are all fine. If you get this, something else is already listening on that label; give the new agent a different --id, or stop the other one.
The flip side is the part this message never shows you: because your own seat is always reclaimable, two agents of yours can be connected at the same time and neither is rejected. Both then serve the tunnel and their replies interleave. Reconnecting is fine; running two on purpose is not.
“Tunnel xxx has type='http' — expected 'tcp'”
You re-used a label that was already registered as an HTTP tunnel via hostwh expose. Pick a different label or delete the existing tunnel.
Queries hang then time out with “Tunnel offline”
The agent disconnected. Check the agent's logs (Docker logs / terminal) — common causes: token revoked, network flap, host machine sleep. The agent auto-reconnects, so a retry usually works once the agent is up again. To see what the server thinks, hostwh tunnels list — the status column says online only while an agent is connected right now, unlike Last seen, which stays warm for hours after it is gone. Bring it back with hostwh tunnels start <id>.
“TLS handshake failed” / “self-signed cert”
The connection string had tls=true / ssl=true (Mongo) or ?sslmode=require (Postgres) in its query parameters. Our forwarder is a plain TCP relay; the agent's side handles TLS to your real DB if needed. We strip these flags when rewriting the URI for the local listener — set the SSL mode toggle to Off in the credential form.
Security model
- Agent auth — the key is HMAC-SHA256 hashed server-side, never stored in plaintext. Revocation is instant from the dashboard or CLI.
- Org scoping — tunnels are bound to an org; a key from org A can't register against a tunnel owned by org B.
- The seat is a write, not a read — registering as the listener requires
tunnel:write, and nothing less. Whoever holds the seat receives everything routed through the tunnel, so a key whose whole vocabulary istunnel:readcannot take it. - One owner at a time — a different principal cannot take over a seat you are holding, and that is checked across every API replica, not just the one your agent happens to be connected to. Stated plainly: a leaked credential of your own still takes the seat — it holds
tunnel:writeon your org, so it could register the moment the agent blinked anyway. The rule is about a different principal, not a compromised one. - DB auth still applies — your DB's username/password is what actually gates DB access. The tunnel just moves bytes.
- SSRF bypass is scoped — only credentials with a valid tunnelId pointing at a TCP tunnel YOU own are allowed to use
localhostconnection strings. - Encrypted at rest — connection string is AES-256-GCM encrypted in the credential row.