Expose a local HTTP server to the internet with one command. No accounts, no API keys, no dashboard.
openhole 3000https://blue-fox.ophl.link → http://localhost:3000
- Install
- Usage
- How it works
- Security
- Self-hosting
- Local development
- Configuration reference
- Limitations
- Contributing
- License
curl -fsSL https://openhole.dev/install.sh | shDownloads the latest release binary from GitHub, verifies its SHA256 checksum (when checksums.txt is published), and installs it to /usr/local/bin (or ~/.local/bin if you lack write access).
Pin a specific version:
OPENHOLE_VERSION=v0.1.1 curl -fsSL https://openhole.dev/install.sh | shgit clone https://github.com/bablilayoub/openhole.git
cd openhole
./scripts/build.shBinaries are written to dist/.
openhole update # download and install the latest release
openhole update --check # check onlyWhen you start a tunnel, OpenHole checks for updates once per day and prints a hint if a newer version is available. Disable with OPENHOLE_SKIP_UPDATE_CHECK=1.
openhole uninstallOr without the CLI installed:
curl -fsSL https://openhole.dev/uninstall.sh | shRemoves openhole from /usr/local/bin, ~/.local/bin, and the Go bin directory (if present). Uses sudo only when needed.
# Expose port 3000 (random subdomain assigned)
openhole 3000
# Request a specific subdomain
openhole 3000 --subdomain myapp
# Forward to a non-default local host
openhole 3000 --host 127.0.0.1
# Point at a custom tunnel server (self-hosted)
openhole 3000 --server wss://tunnel.example.com/tunnel
# Same as --server, via environment variable
export OPENHOLE_SERVER_URL=wss://tunnel.example.com/tunnel
openhole 3000
# Print version
openhole --versionOpenHole v0.1.0
✓ Tunnel ready
→ https://blue-fox.ophl.link
→ forwarding to http://localhost:3000
Requests:
GET /api/users 200 12ms
POST /webhooks/stripe 201 45ms
The CLI reconnects automatically if the connection drops. Your subdomain may change on reconnect unless you use --subdomain.
Internet request
↓
Caddy (TLS termination)
↓
openhole-server ←WebSocket→ openhole CLI → localhost:PORT
- The CLI opens a WebSocket to the tunnel server and registers a subdomain.
- Public HTTPS traffic hits
https://<subdomain>.ophl.link. - The server forwards each HTTP request over the WebSocket to your CLI.
- The CLI proxies the request to your local app and sends the response back.
OpenHole exposes your local service to the public internet. Anyone with the URL can access it.
- Do not tunnel admin panels, databases,
.envfiles, or internal APIs you would not publish publicly. - Tunnels are unauthenticated — anyone can register one if they can reach the server.
- Use
--subdomainfor stable webhook URLs; random subdomains change on reconnect. - Report abuse: abuse@openhole.dev
- Acceptable use policy: openhole.dev/terms
| Protection | Default |
|---|---|
| HTTPS (TLS) | Automatic via Caddy |
| Body size limit | 10 MB per request/response |
| Rate limits | Per-IP registration and request limits |
| Blocked subdomains | Reserved names (admin, api, www, …) |
| Header sanitization | Spoofed X-Forwarded-* stripped before reaching your app |
Run your own tunnel infrastructure with Docker Compose.
| Requirement | Notes |
|---|---|
| VPS | Docker + Docker Compose installed |
| Domain for marketing site | e.g. openhole.dev |
| Domain for tunnels | e.g. ophl.link |
| Cloudflare account | DNS-01 wildcard TLS for *.ophl.link |
| Cloudflare API token | Zone → DNS → Edit on both zones |
cd deployments
cp env.example .envEdit .env and set at minimum:
CLOUDFLARE_API_TOKEN=your_token_here
CADDY_ACME_EMAIL=admin@yourdomain.com
PUBLIC_TUNNEL_DOMAIN=ophl.link
TUNNEL_ENDPOINT_HOST=tunnel.yourdomain.com
NEXT_PUBLIC_SITE_URL=https://yourdomain.dev
NEXT_PUBLIC_TUNNEL_DOMAIN=ophl.linkTRUST_PROXY_HEADERS=true is required in Docker (Caddy sits in front of the server). Never expose port 8080 directly to the internet with this enabled — clients could spoof their IP.
| Zone | Record | Target | Proxy status |
|---|---|---|---|
yourdomain.dev |
A @ |
VPS IP | DNS only (grey cloud) recommended for ACME |
yourdomain.dev |
A www |
VPS IP | DNS only recommended |
yourdomain.dev |
A tunnel |
VPS IP | DNS only (required) |
ophl.link |
A @ |
VPS IP | DNS only (required) |
ophl.link |
A * |
VPS IP | DNS only (required) |
Orange-cloud (proxied) records on tunnel.* or *.ophl.link will break tunnel routing.
docker compose up -d --buildVerify the server is healthy:
curl https://tunnel.yourdomain.com/health
# {"status":"ok"}The CLI defaults to the public OpenHole server. For self-hosted, always pass --server or set OPENHOLE_SERVER_URL:
export OPENHOLE_SERVER_URL=wss://tunnel.yourdomain.com/tunnel
openhole 3000┌─────────────────────────────────────────┐
│ VPS │
│ ┌─────────┐ │
│ │ Caddy │ :80 / :443 │
│ └────┬────┘ │
│ ├── yourdomain.dev → website:3000 │
│ ├── tunnel.* → server:8080 │
│ └── *.ophl.link → server:8080 │
│ │
│ openhole-server :8080 (internal only) │
│ website :3000 (internal only)│
└─────────────────────────────────────────┘
Port 8080 is never published to the host — only Caddy is exposed.
Run the server and client without Docker:
# Terminal 1 — server
PUBLIC_TUNNEL_DOMAIN=ophl.link \
TUNNEL_ENDPOINT_HOST=localhost:8080 \
PUBLIC_URL_SCHEME=http \
TRUST_PROXY_HEADERS=false \
go run ./cmd/openhole-server
# Terminal 2 — client
go run ./cmd/openhole 3000 --server ws://localhost:8080/tunnel
# Terminal 3 — test a proxied request
curl -H "Host: <subdomain>.ophl.link" http://localhost:8080/cd website
npm install
npm run dev./scripts/build.sh./scripts/release.sh v0.1.0This cross-compiles all platform binaries, generates dist/checksums.txt, and prints the gh release create command. Always attach checksums.txt to GitHub releases so the install script can verify downloads.
Install and uninstall scripts (openhole.dev/install.sh, openhole.dev/uninstall.sh) are synced from scripts/ on npm run build (website).
| Flag | Default | Description |
|---|---|---|
port |
— | Local port to expose (required) |
--host |
localhost |
Local host to forward to |
--subdomain |
random | Requested subdomain |
--server |
see below | WebSocket URL of tunnel server |
--verbose |
false |
Print debug info to stderr |
Server URL resolution order: --server → OPENHOLE_SERVER_URL → wss://tunnel.openhole.dev/tunnel
| Variable | Default | Description |
|---|---|---|
PUBLIC_TUNNEL_DOMAIN |
ophl.link |
Domain for public tunnel URLs |
TUNNEL_ENDPOINT_HOST |
tunnel.openhole.dev |
Hostname for WebSocket endpoint |
SERVER_PORT |
8080 |
HTTP listen port |
PUBLIC_URL_SCHEME |
https |
Scheme in URLs sent to clients |
TRUST_PROXY_HEADERS |
false |
Trust X-Forwarded-For from reverse proxy |
MAX_BODY_BYTES |
10485760 |
Max request/response body (10 MB) |
REQUEST_TIMEOUT_SECONDS |
30 |
Per-request timeout |
MAX_CONCURRENT_REQUESTS_PER_TUNNEL |
25 |
Concurrent requests per tunnel |
MAX_TUNNELS_PER_IP |
3 |
Active tunnels per IP |
MAX_REGISTRATIONS_PER_IP_PER_MINUTE |
5 |
Registration rate limit |
MAX_PUBLIC_REQUESTS_PER_IP_PER_MINUTE |
120 |
Public request rate limit |
SUBDOMAIN_HOLD_SECONDS |
30 |
Hold period after disconnect |
BLOCKED_IPS |
— | Comma-separated blocked IPs |
BLOCKED_SUBDOMAINS_EXTRA |
— | Extra reserved subdomain names |
See deployments/env.example for the full template.
- HTTP only — request/response proxying; WebSocket passthrough through tunnels is not supported yet.
- 10 MB body limit per request and response.
- In-memory registry — all tunnels are lost on server restart.
- Random subdomains change on reconnect unless
--subdomainis used (same IP can reclaim its subdomain within the 30s hold window). - No authentication — tunnel registration is open to anyone who can reach the server.
Before going live on your VPS:
cp deployments/env.example deployments/.envand fill inCLOUDFLARE_API_TOKEN- Set Cloudflare DNS records (grey cloud / DNS only for
tunnel.*and*.ophl.link) cd deployments && docker compose up -d --build- Verify:
curl https://tunnel.openhole.dev/health - Cut release:
./scripts/release.sh v0.1.0→ tag →gh release createwith binaries +checksums.txt - Test CLI:
openhole 3000against production - Verify install script:
curl -fsSL https://openhole.dev/install.sh | head -5
See SECURITY.md for vulnerability and abuse reporting.
Contributions are welcome. See CONTRIBUTING.md for development setup, testing, and pull request guidelines.
go test -race -count=1 ./...MIT — see LICENSE.