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.

The same tunnel works for both MongoDB and PostgreSQL credentials (and any future TCP DB we add). The only thing that differs is the target port and the connection string format. Examples below show both.

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).
Compared to the direct flow (BYO MongoDB / BYO PostgreSQL): same Vector Store / AI Memory features, but bytes route through a reverse tunnel instead of a direct internet connection. Adds ~30–50ms per query — typically <10% of the total DB latency.

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 / PostgreSQL

Each 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.

Keys start with 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.

One key per agent. Give the key the name of the machine or container that will hold it, and grant it 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
Always pass --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 tunnelId it 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:latest
--network=host works on Linux and lets the container reach DBs on the host's localhost. 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:
      - postgres

Verify the tunnel is online: Settings → Tunnels should show your tunnel with a TCP badge and a green Online indicator.

Do this before Step 3. The credential form only offers the tunnel dropdown when at least one tunnel is online — with the agent stopped the field is not greyed out, it simply is not there. If you cannot find it, the agent is not connected.

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 15cd1479f0

Needs 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.

One agent per tunnel. Two agents on the same account are not turned away — the seat check only stops other people. Both join the tunnel, both receive every stream and both answer, so their replies interleave. It does not fail; it answers wrong. Stop the old agent before starting another. 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_database

    Auth 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=require here 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 --id labels).
  • 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 is tunnel:read cannot 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:write on 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 localhost connection strings.
  • Encrypted at rest — connection string is AES-256-GCM encrypted in the credential row.