A simple HTTP tunneling tool that exposes your local development server to the internet through custom subdomains.
Wormhole creates a tunnel between your local development server and the internet, allowing you to:
- Share your local web application with others instantly
- Test webhooks and APIs that require public URLs
- Demo your work-in-progress applications
- Access your local development environment from anywhere
go run cmd/server/main.gogo run cmd/client/main.go -server=http://localhost:8080 -domain=myapp -local=http://localhost:3000Your local server is now available at: http://myapp.localhost:8080
Download the latest release for your platform from the Releases page.
Linux:
tar -xzf wormhole-client-linux-amd64.tar.gz
chmod +x wormhole
./wormhole -domain=myapp -local=http://localhost:3000macOS:
The binaries are not signed with an Apple Developer certificate. After downloading, you need to remove the quarantine attribute:
tar -xzf wormhole-client-darwin-arm64.tar.gz
xattr -d com.apple.quarantine wormhole
chmod +x wormhole
./wormhole -domain=myapp -local=http://localhost:3000Alternatively, you can right-click the binary in Finder, select "Open", and confirm in the dialog.
git clone https://github.com/dbackowski/wormhole.git
cd wormhole
go mod download
make buildThis produces bin/client/wormhole and bin/server/wormhole.
To run the test suite:
make testgo run cmd/server/main.go -port=8080Optional flags:
-port: Port to run the server on (default: 8080)-host: Public hostname of this server (e.g.wormhole.tools). When set,/healthand/metricsare only served on this host; requests to subdomains are tunneled to the matching client. Required for any deployment on a real domain — see the note below.-auth-token: Authentication token required for client connections-debug: Enable debug mode-version: Print version and exit
Note on
-host: When-hostis unset, the server falls back to a heuristic — anyHostcontaining a dot is treated as a tunnel subdomain. This works for local development (*.localhost) and bare-IP access, but on a real domain it has two consequences: requests to your apex (e.g.wormhole.tools/health) are treated as a tunnel and return502instead of serving health/metrics, and the apex's first label (wormhole) becomes a reserved subdomain that shadows any client claiming it. Set-hostto your public hostname in production to route exactly.
go run cmd/client/main.go -server=http://localhost:8080 -domain=mysubdomain -local=http://localhost:3000Required flags:
-domain: Your unique subdomain name-local: URL of your local development server
Optional flags:
-server: Server URL (default: https://wormhole.tools)-auth-token: Authentication token for server connection-webui-port: Port for the Web UI dashboard (default: 4040)-version: Print version and exit
Note: Unlike the server, the client does not read environment variables. It takes its configuration from flags or from
~/.wormhole/config(see Authentication).
✅ Custom Subdomains - Choose your own subdomain name
✅ Real-time Tunneling - Instant request forwarding via WebSockets
✅ Header Forwarding - End-to-end headers are forwarded; hop-by-hop headers are stripped, Host is set to the tunnel host, and X-Forwarded-For/-Proto/-Host are added so your local app sees the original client IP and public URL (standard proxy behavior)
✅ Multiple Clients - Support for multiple simultaneous tunnels
✅ Automatic Cleanup - Domains are released when clients disconnect
✅ Automatic Reconnect - The client retries with exponential backoff if the connection drops
✅ Web UI Dashboard - Monitor tunneled requests in a browser at http://localhost:4040
✅ Optional Authentication - Secure your server with token-based auth
- Frontend Development: Share your React/Vue/Angular app with team members
- API Testing: Test webhook endpoints from external services
- Mobile Development: Test your local API with mobile apps
- Client Demos: Show work-in-progress to clients without deployment
When the client is running, a dashboard is available at http://localhost:4040 where you can:
- See the active tunnel URL
- View recent requests with method, path, status code, and timestamp
- Inspect request/response headers and bodies
- Clear the request history with the Clear button (also clears the terminal view)
To use a different port:
go run cmd/client/main.go -domain=myapp -local=http://localhost:3000 -webui-port=5050You can secure your Wormhole server by requiring an authentication token for client connections.
Server:
./wormhole-server -port=8080 -auth-token=my-secret-tokenClient:
./wormhole -domain=myapp -local=http://localhost:3000 -auth-token=my-secret-tokenBoth the server and client can read the token from ~/.wormhole/config:
AUTH_TOKEN=my-secret-token
The -auth-token flag takes precedence over the config file. When a token is configured on the server, clients must provide the same token to connect — unauthenticated connections will be rejected with HTTP 401.
graph TD
A[Browser] -->|HTTP Request| B[Wormhole Server<br>myapp.server:8080]
B -->|WebSocket| C[Wormhole Client]
C -->|HTTP| D[Local Dev Server<br>localhost:3000]
D -->|Response| C
C -->|WebSocket| B
B -->|HTTP Response| A
- The server listens for client connections and HTTP requests
- Clients connect via WebSocket and claim a subdomain
- HTTP requests to
subdomain.server:portare forwarded to the client over WebSocket - The client forwards requests to your local server and returns responses
| Endpoint | Description |
|---|---|
/health |
Health check, returns OK. Served on the server's own host only |
/metrics |
Active connection count. Served on the server's own host only |
/* |
Everything else is tunneled to the matching subdomain client |
Client connections are not a path. The server recognizes them as WebSocket upgrades carrying the X-Wormhole-Client header, at whatever path the client dialed, so no path is reserved on your tunnel.
Statuses the server returns for tunneled requests, rather than passing through from your local app:
| Status | Meaning |
|---|---|
400 Bad Request |
The Host header has no subdomain to route on |
413 Request Entity Too Large |
Request body exceeds 10 MB |
501 Not Implemented |
WebSocket upgrade request (see Limitations) |
502 Bad Gateway |
No client is connected for the subdomain, the tunnel dropped mid-request, or the local server was unreachable, too slow, or returned a body over 10 MB |
503 Service Unavailable |
The client is already handling 64 concurrent requests |
504 Gateway Timeout |
No response from the client within 15 seconds |
To run your own Wormhole server, you need:
- Wildcard DNS - Point
*.yourdomain.comto your server so that subdomain-based routing works - TLS termination - Wormhole does not handle TLS natively. Use a reverse proxy like Caddy or nginx to terminate TLS
- Run the server with
-hostset to your public hostname so apex requests (health/metrics) are routed correctly:./wormhole-server -port=8080 -host=yourdomain.com
Forwarded headers behind TLS termination: The server adds
X-Forwarded-Prototo each tunneled request. Because TLS is terminated by your reverse proxy, an inboundX-Forwarded-Proto(set by Caddy/nginx) is always honored; when absent, the value defaults tohttpsif-hostis set andhttpotherwise.X-Forwarded-Forappends the incoming peer to any existing chain, so configure your proxy to forward the real client IP.
docker build -t wormhole .
docker run -p 8080:8080 -e HOST=yourdomain.com -e AUTH_TOKEN=my-secret-token wormholeThe server reads AUTH_TOKEN and HOST from the environment (equivalent to the -auth-token and -host flags), so secrets stay out of the process command line. Flags, when provided, take precedence over the environment.
The repository includes a fly.toml for deployment to Fly.io. Set your FLY_API_TOKEN as a GitHub secret for automatic deploys on push to main.
- No WebSocket passthrough - WebSocket upgrade requests to tunneled services are rejected with
501 Not Implemented - No built-in TLS - Requires a reverse proxy for HTTPS
- 10 MB request body limit - Requests larger than 10 MB are rejected with
413 Request Entity Too Large - 10 MB response body limit - Responses larger than 10 MB from your local server are rejected with
502 Bad Gateway. Bodies travel base64-encoded inside a WebSocket frame capped at 16 MB, which is what sets both limits - 10 second request timeout - The client gives up on your local server after 10 seconds and returns
502 Bad Gateway. The server independently stops waiting after 15 seconds and returns504 Gateway Timeout - 64 concurrent requests per tunnel - Beyond that the client returns
503 Service Unavailableuntil a slot frees up - Redirects are passed through unchanged - The client does not follow redirects from your local server. A
Locationheader pointing athttp://localhost:3000is sent to the browser as-is, taking it off the tunnel. Configure your app to emit relative redirects, or to build absolute URLs from theX-Forwarded-HostandX-Forwarded-Protoheaders - Reconnect is time-limited - If the connection drops, the client retries with exponential backoff (500 ms up to 30 s) for 5 minutes, then exits. Requests in flight when the connection drops fail with
502 Bad Gateway - Reconnect can be delayed after an unclean drop - If the connection dies without closing cleanly (laptop sleep, Wi-Fi drop), the server only notices when its heartbeat times out, up to 60 seconds later. Until then the subdomain is still held by the stale connection and reconnect attempts fail as already taken. The client keeps retrying, so it recovers on its own
- Go 1.25 or later
- A client and server from the same release line. Clients up to
v1.0.5identified themselves by connecting to/ws; the server now requires theX-Wormhole-Clientheader, so those clients cannot register and fail withwebsocket: bad handshake. Upgrade the client - Available port for the server (default: 8080)
- Local development server to tunnel
Released under the MIT License.
Feel free to open issues and submit pull requests to improve Wormhole!


