README
¶
OpenHole
Expose a local HTTP server to the internet with one command. No accounts, no API keys, no dashboard.
openhole 3000
https://blue-fox.ophl.link → http://localhost:3000
Table of contents
- Install
- Usage
- How it works
- Security
- Self-hosting
- Local development
- Configuration reference
- Limitations
- Contributing
- License
Install
Install script (macOS / Linux)
curl -fsSL https://openhole.dev/install.sh | sh
Downloads 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 | sh
Build from source
git clone https://github.com/bablilayoub/openhole.git
cd openhole
./scripts/build.sh
Binaries are written to dist/.
Update
openhole update # download and install the latest release
openhole update --check # check only
openhole status # show version, active tunnel, saved subdomains
When 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.
Uninstall
openhole uninstall
Or without the CLI installed:
curl -fsSL https://openhole.dev/uninstall.sh | sh
Removes openhole from /usr/local/bin, ~/.local/bin, and the Go bin directory (if present). Uses sudo only when needed.
Usage
# 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 --version
What you see
OpenHole 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 — if the public URL changes, OpenHole prints a warning.
With --subdomain, the CLI stores a reclaim token locally (~/.config/openhole/reclaim.json) so you can reconnect from a different network during the hold window after a disconnect.
Check a running tunnel from another terminal:
openhole status
How it works
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.
Security
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; a reclaim token lets you keep the name across reconnects (even from a new IP) during the hold window. - Report abuse: abuse@openhole.dev
- Acceptable use policy: openhole.dev/terms
Built-in protections
| 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 |
| WebSocket passthrough | Upgrade requests relayed to your local server |
| Protocol validation | Message size, header count, and CRLF injection limits |
| Client concurrency | 25 concurrent local requests per tunnel (matches server default) |
Self-hosting
Run your own tunnel infrastructure with Docker Compose.
Prerequisites
| 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 |
1. Configure environment
cd deployments
cp env.example .env
Edit .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.link
TRUST_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.
2. DNS records (Cloudflare)
| 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.
3. Deploy
docker compose up -d --build
Verify the server is healthy:
curl https://tunnel.yourdomain.com/health
# {"status":"ok"}
4. Connect the CLI to your server
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
Architecture
┌─────────────────────────────────────────┐
│ 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.
Local development
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/
Website (landing page)
cd website
npm install
npm run dev
Build binaries
./scripts/build.sh
Cut a release
./scripts/release.sh v0.1.0
This 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).
Configuration reference
CLI flags
| 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
Server environment variables
| 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.
Limitations
- HTTP request/response proxying with WebSocket passthrough for live reload, HMR, and real-time apps.
- 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. Named subdomains are held after disconnect; the same IP or a valid reclaim token can reclaim them within the hold window. - No authentication — tunnel registration is open to anyone who can reach the server.
Launch checklist (operators)
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.
Contributing
Contributions are welcome. See CONTRIBUTING.md for development setup, testing, and pull request guidelines.
go test -race -count=1 ./...
License
MIT — see LICENSE.