NullBore
On-demand tunnels with a kill switch.
NullBore exposes your localhost to the internet — temporarily, intentionally, and programmatically. Every tunnel has a TTL. No permanent attack surface.
What is NullBore?
NullBore is a tunnel relay server and client. You run the client on your machine, it connects to a NullBore server, and you get a public HTTPS URL that routes traffic to your local port.
Internet → tunnel.nullbore.com → WebSocket relay → your laptop:3000
Key Features
- Time-limited by default — every tunnel gets a TTL and closes itself
- TLS everywhere — automatic HTTPS via Let's Encrypt
- Subdomain routing —
yourapp.yourname.nullbore.com - Idle TTL mode — tunnel stays alive while there's traffic, expires after inactivity
- API-first — open, close, and manage tunnels programmatically
- Dashboard — see active tunnels, traffic stats, API keys
- Self-hostable — one binary, zero dependencies, MIT licensed
- Bandwidth metering — real byte-level tracking per tunnel
Hosted vs Self-Hosted
Hosted (nullbore.com): We run the infrastructure. Sign up, get an API key, start tunneling. Free tier available.
Self-hosted: Run your own NullBore server. All features unlocked, no limits, no cost. Download the binary or build from source.
Quick Example
# Install the client
curl -sSL https://nullbore.com/install.sh | sh
# Open a tunnel to localhost:3000
nullbore open --port 3000
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000
Quickstart
Get a public URL for your local server in under a minute.
1. Install the client
curl -fsSL https://nullbore.com/install.sh | sh
No sudo needed — installs to ~/.local/bin. Make sure it's in your PATH:
export PATH="$PATH:$HOME/.local/bin"
Or download directly from GitHub releases.
2. Get an API key
- Sign up at nullbore.com
- Go to the dashboard
- Click + New Key and copy it
3. Configure
Edit ~/.config/nullbore/config.toml (created by the installer):
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_key_here"
Or use environment variables:
export NULLBORE_API_KEY="nbk_your_key_here"
Note: Use
export— without it, the variable isn't passed to child processes.
4. Open a tunnel
# Expose localhost:3000
nullbore open 3000
# → https://a7f3bc.tunnel.nullbore.com → localhost:3000
# With a custom name (Dev+ with account subdomain)
nullbore open 3000 --name myapp
# → https://myapp.yourname.nullbore.com → localhost:3000
# With a 30-minute TTL
nullbore open 3000 --ttl 30m
# With basic auth
nullbore open 3000 --auth admin:s3cret
# Multiple tunnels at once
nullbore open 3000 8080 5432
nullbore open -p 3000:api -p 8080:web # named (Dev+)
5. Use it
Your local server is now accessible at the public URL. Share it, point webhooks at it, or let an AI agent call it.
The tunnel supports HTTP, WebSocket, and SSE out of the box.
6. Close it
# Close by name or slug
nullbore close myapp
# Close by ID
nullbore close a7f3bc
# Or just wait — it closes itself when the TTL expires
Quick reference
nullbore open <port> Open a tunnel
nullbore open -p 3000:api Open a named tunnel
nullbore open --port 3000 --auth u:p Tunnel with basic auth
nullbore list List active tunnels
nullbore close <id-or-name> Close a tunnel
nullbore requests <id> Inspect recent requests
nullbore status Check server connection
nullbore daemon Config-driven persistent mode
nullbore update Self-update to latest version
nullbore version Show client version
nullbore help Show help
Global flags:
--debug / -v Verbose output (relay logs, etc.)
What's next?
- Configuration reference — all config options
- Managing tunnels — list, close, extend
- Subdomains — named tunnels and account subdomains
- API reference — automate with the REST API
- Self-hosting — run your own server
Installation
Client
The NullBore client is a single binary with no dependencies.
Install script (macOS / Linux)
curl -sSL https://nullbore.com/install.sh | sh
This downloads the latest release for your platform and puts it in /usr/local/bin/nullbore.
Download from GitHub
Pre-built binaries for all platforms are available on the releases page.
| Platform | Binary |
|---|---|
| Linux (amd64) | nullbore-linux-amd64 |
| Linux (arm64) | nullbore-linux-arm64 |
| macOS (amd64) | nullbore-darwin-amd64 |
| macOS (arm64) | nullbore-darwin-arm64 |
| Windows (amd64) | nullbore-windows-amd64.exe |
Build from source
git clone https://github.com/nullbore/nullbore-client.git
cd nullbore-client
go build -o nullbore ./cmd/nullbore/
Requires Go 1.22+.
Verify installation
nullbore version
# nullbore v0.1.0 (linux/amd64)
Server (self-hosted)
See Self-Hosting: Server Setup for server installation.
Configuration
The NullBore client reads configuration from ~/.config/nullbore/config.toml.
Legacy path
~/.nullbore/config.tomlis still supported. The installer auto-migrates to the XDG location.
Config file
# ~/.config/nullbore/config.toml
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_api_key_here"
default_ttl = "1h"
debug = false
# Persistent tunnels (managed by the daemon)
[[tunnels]]
name = "api"
port = 3000
[[tunnels]]
name = "web"
port = 8080
The installer creates this file automatically. Edit it directly — there is no config set command.
Persistent tunnels
Define [[tunnels]] sections to have the daemon manage tunnels automatically. Changes are hot-reloaded every 10 seconds — no restart needed.
[[tunnels]]
name = "api" # tunnel name (becomes the subdomain slug)
port = 3000 # local port to expose
host = "localhost" # target host (default: localhost)
ttl = "2h" # TTL override (default: server default)
# auth = "user:pass" # require HTTP basic auth before relaying (optional)
[[tunnels]]
name = "web"
port = 8080
Then run nullbore daemon to start.
Environment variables
Environment variables override config file values. See the full list in the CLI Reference.
Important: Use
exportwhen setting environment variables — without it, the variable won't be passed to child processes.# Wrong — shell variable only, nullbore won't see it: NULLBORE_API_KEY="nbk_..." nullbore open 3000 # Right: export NULLBORE_API_KEY="nbk_..." nullbore open 3000
Debug mode
Enable verbose logging (relay connections, control channel, dashboard polling):
debug = true
Or pass --debug / -v to any command:
nullbore open --port 3000 --debug
Precedence
Environment variables > Config file > Defaults
Full CLI reference
See CLI Reference for all commands, flags, and environment variables — auto-generated from the source code.
CLI Reference
Auto-generated from the client source code. Do not edit manually. Client version:
0.1.0-dev
nullbore open
Open one or more tunnels to expose local ports
nullbore open <port>
nullbore open --port <port> [--name <name>] [--ttl <duration>]
nullbore open -p <port>[:<name>] [-p <port>[:<name>] ...]
nullbore open <port> [<port> ...]
Creates a tunnel on the server and relays traffic from the public URL to your local port. Stays open until the TTL expires or you press Ctrl+C.
Requires an API key.
Flags:
--auth Basic auth for tunnel access (user:pass)
--host Target host (for Docker/remote services) (default: localhost)
--name Tunnel name / custom subdomain (Dev+ plans)
--port Local port to expose (single tunnel)
--ttl Time-to-live (e.g. 30m, 2h, 24h) (default: 1h)
-p <port> or <port>:<name> Repeatable. Open multiple tunnels.
Format: PORT or PORT:NAME
Example: -p 3000:api -p 8080:web
Examples:
nullbore open 3000 # expose localhost:3000
nullbore open --port 3000 --name myapp # with custom subdomain
nullbore open --port 3000 --ttl 30m # 30-minute TTL
nullbore open -p 3000:api -p 8080:web # multiple named tunnels
nullbore open 3000 8080 5432 # multiple tunnels (positional)
nullbore open --port 3000 --auth admin:s3cret # with basic auth
nullbore list
List active tunnels
nullbore list
Shows all tunnels currently open for your API key, with their IDs, slugs, ports, and expiry times.
Requires an API key.
nullbore close
Close a tunnel
nullbore close <tunnel-id-or-name>
Closes the specified tunnel. You can use the full tunnel ID, the short ID prefix from nullbore list, or the tunnel's slug/name.
Arguments: The tunnel ID (or first 8 chars), slug, or name.
Requires an API key.
nullbore requests
Inspect recent HTTP requests to a tunnel
nullbore requests <tunnel-id-or-slug> [--limit N]
Shows recent HTTP requests that hit your tunnel — method, path, body size, and source IP. Useful for debugging webhooks.
Arguments: The tunnel ID or slug to inspect.
Requires an API key.
Flags:
--limit Number of requests to show (default: 20)
nullbore status
Check server connection and auth status
nullbore status
Pings the tunnel server and reports its version. Shows whether an API key is configured.
nullbore daemon
Run in dashboard-driven persistent mode
nullbore daemon
Connects to the NullBore dashboard and manages tunnels based on your dashboard configuration. Tunnels activate/deactivate remotely without restarting the daemon.
For static/headless mode (Docker), set NULLBORE_TUNNELS instead:
NULLBORE_TUNNELS=host:port:slug,host:port:slug,...
Example: NULLBORE_TUNNELS=webapp:3000:my-app,db:5432:my-db
Requires an API key.
nullbore update
Check for updates and self-update
nullbore update
nullbore update --check
Checks GitHub for a newer release. Without --check, downloads and replaces the binary.
Flags:
--check Only check for updates, don't install
nullbore version
Show client version
nullbore version
nullbore help
Show help
nullbore help
Environment Variables
Environment variables override config file values.
| Variable | Description | Default |
|---|---|---|
NULLBORE_SERVER | Tunnel server URL (must include https://) | https://tunnel.nullbore.com |
NULLBORE_API_KEY | API key for authentication | — |
NULLBORE_DASHBOARD | Dashboard URL (for daemon mode) | https://nullbore.com |
NULLBORE_TLS_SKIP_VERIFY | Skip TLS certificate verification (set to 1 or true) | — |
NULLBORE_TUNNELS | Static tunnel list for Docker/headless mode (format: host:port:slug,...) | — |
NULLBORE_INSTALL_DIR | Override install directory for install.sh | ~/.local/bin |
NULLBORE_VERSION | Pin a specific version for install.sh | — |
Important: Use
exportwhen setting environment variables in your shell. Withoutexport, the variable is only a shell variable and won't be passed tonullbore.# Wrong: NULLBORE_API_KEY="nbk_..." # shell variable only nullbore open 3000 # won't see the key # Right: export NULLBORE_API_KEY="nbk_..." nullbore open 3000
Config File
The client reads ~/.config/nullbore/config.toml on startup.
Legacy path
~/.nullbore/config.tomlis still supported.
# ~/.config/nullbore/config.toml
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_key_here"
default_ttl = "1h"
debug = false
# Persistent tunnels (managed by daemon)
[[tunnels]]
name = "api"
port = 3000
# auth = "user:pass" # optional: require basic auth on this tunnel
Edit the file directly — there is no config set command.
Precedence
Environment variables > Config file > Defaults
Opening Tunnels
Basic usage
# Expose a local port (positional shorthand)
nullbore open 3000
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000
# Or with explicit flag
nullbore open --port 3000
This creates a tunnel with a random slug and a 1-hour default TTL.
Named tunnels
On Dev and Pro plans, users with a claimed account subdomain can choose their tunnel URL:
nullbore open --port 3000 --name myapp
# ✓ https://myapp.pj.nullbore.com → localhost:3000
Named tunnels live under your account subdomain (*.yourname.nullbore.com). Without a claimed subdomain, tunnels get random slugs on *.tunnel.nullbore.com.
Names must be 2-63 characters, lowercase alphanumeric and hyphens only.
TTL (time to live)
Every tunnel has a TTL — the maximum time it stays open. When it expires, the tunnel closes and the client exits cleanly (no reconnect).
nullbore open 3000 --ttl 30m # 30 minutes
nullbore open 3000 --ttl 4h # 4 hours
nullbore open 3000 --ttl 0 # persistent (Dev plan max)
TTL limits depend on your plan:
| Plan | Max TTL |
|---|---|
| Free | 2 hours |
| Dev | Persistent |
| Pro | Unlimited (persistent) |
Idle TTL mode
With --idle, the TTL becomes an inactivity timeout instead of a hard deadline. The tunnel stays alive as long as there's traffic, and only expires after the TTL period of silence.
# Stay alive while there's traffic, close after 30 min of silence
nullbore open 3000 --ttl 30m --idle
This is useful for:
- Dev servers you want up while you're working
- MCP servers that should be available while an agent session is active
- Demo environments that clean themselves up
Basic auth
Protect your tunnel with HTTP basic auth. Visitors are challenged with a 401 before any request reaches your local service:
nullbore open 3000 --auth admin:s3cret
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000 [auth protected]
The Authorization header is stripped before forwarding — your local service never sees it. Auth also works in config.toml for daemon tunnels:
[[tunnels]]
port = 3000
auth = "admin:s3cret"
Multiple tunnels
Open several tunnels at once:
# With the -p flag (PORT:NAME format)
nullbore open -p 3000:api -p 8080:web -p 5432:db
# ✓ https://api.pj.nullbore.com → localhost:3000
# ✓ https://web.pj.nullbore.com → localhost:8080
# ✓ https://db.pj.nullbore.com → localhost:5432
# Positional shorthand (random slugs)
nullbore open 3000 8080 5432
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000
# ✓ https://b2e91d.tunnel.nullbore.com → localhost:8080
# ✓ https://c4f7a0.tunnel.nullbore.com → localhost:5432
Named tunnels (the -p PORT:NAME form) require a Dev+ plan with a claimed account subdomain. Without one, names are silently ignored and you get random slugs.
Flags apply to all tunnels in the command:
nullbore open 3000 8080 --ttl 2h --auth demo:pass
Via the API
curl -X POST https://tunnel.nullbore.com/v1/tunnels \
-H "Authorization: Bearer nbk_your_key" \
-H "Content-Type: application/json" \
-d '{"local_port": 3000, "ttl": "30m", "name": "api", "auth_user": "admin", "auth_pass": "s3cret"}'
See API: Tunnels for full details.
Managing Tunnels
List active tunnels
nullbore list
Output:
ID NAME PORT TTL EXPIRES IN REQUESTS TRAFFIC
a7f3bc — 3000 1h 42m 128 4.2 MB
heroapp heroapp 8080 168h 6d 23h 1,024 52 MB
Check status
nullbore status heroapp
Tunnel: heroapp
URL: https://heroapp.pj.nullbore.com
Local: localhost:8080
TTL: 168h (idle)
Expires: 2026-04-06 14:30:00 UTC
Requests: 1,024
Traffic: 52 MB in / 128 MB out
Created: 2026-03-30 14:30:00 UTC
Close a tunnel
# By name
nullbore close heroapp
# By slug/ID
nullbore close a7f3bc
Extend a tunnel
Extend the TTL of an active tunnel:
# Via CLI (future)
nullbore extend heroapp --ttl 4h
# Via API
curl -X PUT https://tunnel.nullbore.com/v1/tunnels/TUNNEL_ID/extend \
-H "Authorization: Bearer nbk_your_key" \
-H "Content-Type: application/json" \
-d '{"ttl": "4h"}'
Auto-reconnect
The client automatically reconnects if the connection drops. It uses exponential backoff with jitter to avoid thundering herds.
connection lost, reconnecting in 1.2s...
connection lost, reconnecting in 2.8s...
reconnected, tunnel restored: https://heroapp.pj.nullbore.com
Reconnect re-registers the tunnel with the server, so the URL stays the same as long as it hasn't expired.
Dashboard
Active tunnels are visible in the dashboard at:
- Hosted: nullbore.com/dashboard
- Self-hosted:
https://your-server/dash
The dashboard shows real-time tunnel status, traffic stats, and lets you close tunnels with one click.
Idle TTL & Auto-Renew
The problem
Fixed TTLs are great for security — tunnels can't accidentally stay open forever. But sometimes you want a tunnel that stays alive while you're actively using it.
Idle TTL mode
Dev and Pro plan feature. Idle TTL is not available on the free tier — free tunnels have a fixed 2-hour session limit.
When you open a tunnel with --idle, the TTL becomes an inactivity timeout:
nullbore open --port 3000 --ttl 30m --idle
- Every HTTP request or byte of traffic resets the expiry clock
- The tunnel stays open as long as there's traffic within the TTL window
- After 30 minutes of zero activity, the tunnel closes
How it works
14:00 Tunnel opened (expires 14:30)
14:15 Request received → expiry reset to 14:45
14:20 Request received → expiry reset to 14:50
14:50 No activity for 30 min → tunnel closes
Via the API
curl -X POST https://tunnel.nullbore.com/v1/tunnels \
-H "Authorization: Bearer nbk_your_key" \
-H "Content-Type: application/json" \
-d '{"local_port": 3000, "ttl": "30m", "idle_ttl": true}'
Use cases
- Development sessions: Keep the tunnel alive while you're coding, auto-close when you stop
- MCP server exposure: Available while the AI agent is working, closes when the session ends
- Demo environments: Up while someone is looking, gone when they leave
- Webhook testing: Stays alive while you're iterating, doesn't linger after
Subdomain Routing
Every tunnel gets a unique URL. The format depends on your plan:
| Plan | URL format | Example |
|---|---|---|
| Free | {random}.tunnel.nullbore.com | a7f3bc.tunnel.nullbore.com |
| Dev | {name}.{you}.nullbore.com | api.heroapp.nullbore.com |
| Pro | {name}.tunnel.yourdomain.com | api.tunnel.yourcompany.com |
Random slugs (all plans)
When you open a tunnel without a name, you get a random hex slug:
nullbore open 3000
# → https://a7f3bc.tunnel.nullbore.com
These are always available on every plan. Tunnels are also accessible via path-based URLs as a fallback:
https://tunnel.nullbore.com/t/a7f3bc
Named tunnels (Dev+)
Named tunnels require two things:
- A Dev or Pro plan
- A claimed account subdomain (see below)
nullbore open 3000 --name api
# → https://api.heroapp.nullbore.com
Named tunnels keep the same URL across reconnects and server restarts — no need to update webhook URLs or client bookmarks.
Without a claimed subdomain, --name is ignored and you get a random slug.
Account subdomains (Dev+)
Claim a personal subdomain from your dashboard. All your named tunnels live under it:
https://api.heroapp.nullbore.com
https://web.heroapp.nullbore.com
Visiting just heroapp.nullbore.com routes to your most recent active tunnel.
Setup
- Go to Dashboard → Account Subdomain
- Claim a subdomain (e.g.
heroapp) — takes ~2 minutes to provision a TLS certificate - Open named tunnels — they appear under your subdomain automatically
nullbore open -p 3000:api -p 8080:web
# → https://api.heroapp.nullbore.com
# → https://web.heroapp.nullbore.com
When you upgrade to a paid plan, a subdomain is auto-suggested based on your email. You can change it from the dashboard.
Rules
- 4–32 characters, lowercase letters/numbers/hyphens
- No leading, trailing, or consecutive hyphens
- Common names are reserved (api, www, admin, etc.)
- One subdomain per account — release the old one to claim a new one
Custom domains (Pro)
Pro plans can use your own domain for tunnel URLs. See Custom Domains for setup instructions.
https://api.tunnel.yourcompany.com → localhost:3000
https://staging.tunnel.yourcompany.com → localhost:8080
Self-hosted subdomain setup
To enable subdomain routing on a self-hosted server:
- Configure a wildcard DNS record:
*.tunnel.yourdomain.com → your-server-ip - Start the server with
--base-domain:
nullbore-server \
--host 0.0.0.0 \
--port 443 \
--base-domain tunnel.yourdomain.com \
--tls-domain tunnel.yourdomain.com \
--tls-email [email protected]
For account subdomains (two-level routing), also set --account-domain:
nullbore-server \
--base-domain tunnel.yourdomain.com \
--account-domain yourdomain.com
This enables {tunnel}.{account}.yourdomain.com routing.
Custom Domains
Pro plan feature — bring your own domain for tunnel URLs.
Custom domains let you use your own domain instead of *.yourname.nullbore.com. You set up one wildcard CNAME, and all your named tunnels work under it automatically.
How it works
Instead of api.yourname.nullbore.com, your tunnels become api.tunnel.yourcompany.com:
https://api.tunnel.yourcompany.com → localhost:3000
https://staging.tunnel.yourcompany.com → localhost:8080
https://admin.tunnel.yourcompany.com → localhost:9090
Setup
1. Add a wildcard CNAME
Point a subdomain and its wildcard to NullBore:
*.tunnel.yourcompany.com CNAME tunnel.nullbore.com
tunnel.yourcompany.com CNAME tunnel.nullbore.com
Tip: You can use any subdomain —
tunnel.,dev.,preview., etc. Just make sure both the wildcard and the base record exist.
2. Register in the dashboard
Go to Dashboard → Custom Domains and add your domain (e.g. tunnel.yourcompany.com). NullBore verifies the CNAME and provisions a TLS certificate automatically.
3. Open tunnels
Named tunnels automatically resolve under your custom domain:
nullbore open 3000 --name api
# → https://api.tunnel.yourcompany.com → localhost:3000
No extra flags needed — NullBore routes based on the domain and your account.
Why a subdomain?
You CNAME a subdomain like tunnel.yourcompany.com (not yourcompany.com directly) because:
- Wildcard CNAME — one DNS record covers all tunnel names (
*.tunnel.yourcompany.com) - No per-tunnel DNS changes — create tunnels freely without touching DNS
- No conflict — your main domain, email MX records, etc. are unaffected
- Clean separation —
tunnel.makes it obvious what the subdomain is for
Self-hosted
On a self-hosted server, add the custom domain to your TLS configuration:
nullbore-server \
--base-domain tunnel.yourdomain.com \
--tls-domain tunnel.yourdomain.com,tunnel.yourcompany.com
The server will automatically provision TLS certificates for both domains via Let's Encrypt.
Status: Custom domain support is in active development. The server-side routing and TLS infrastructure are in place. Dashboard UI for domain registration is coming soon.
Authentication
All API requests require an API key.
API keys
API keys are prefixed with nbk_ and look like:
nbk_a855d00c08bcc5baaa5fa1581245973b6491d95ffb4991dc
Get your API key from the dashboard.
Sending your key
Pass your API key in the Authorization header:
curl https://tunnel.nullbore.com/v1/tunnels \
-H "Authorization: Bearer nbk_your_key"
Or as a query parameter (useful for WebSocket connections):
wss://tunnel.nullbore.com/ws/control?tunnel_id=X&api_key=nbk_your_key
Key limits
| Plan | API Keys |
|---|---|
| Free | 1 |
| Dev | 5 |
| Pro | 10 |
Unauthenticated endpoints
These endpoints don't require authentication:
GET /health— server health checkGET /t/{slug}— tunnel proxy (public by design)GET *.tunnel.nullbore.com— subdomain tunnel proxy
Errors
// Missing or invalid key
HTTP 401
{"error": "unauthorized"}
// Rate limited
HTTP 429
{"error": "rate limit exceeded"}
API: Tunnels
Base URL: https://tunnel.nullbore.com/v1
All endpoints require authentication via Authorization: Bearer nbk_your_key.
Create a tunnel
POST /v1/tunnels
Request
{
"local_port": 3000,
"name": "myapp",
"ttl": "1h",
"idle_ttl": true
}
| Field | Type | Required | Description |
|---|---|---|---|
local_port | int | yes | Local port to tunnel (1-65535) |
name | string | no | Custom subdomain name (Dev+ plans). 2-63 chars, lowercase alphanumeric + hyphens. |
ttl | string | no | Time to live (Go duration: 30m, 4h, 168h). Default: 1h. |
idle_ttl | bool | no | If true, TTL resets on activity (idle timeout mode). Default: false. |
Response
{
"id": "b1d0df1b-9b8c-43e1-b193-e1b3ff24e986",
"slug": "myapp",
"client_id": "client-1",
"local_port": 3000,
"name": "myapp",
"ttl": "1h0m0s",
"mode": "relay",
"created_at": "2026-03-30T14:30:00Z",
"expires_at": "2026-03-30T15:30:00Z",
"idle_ttl": true,
"bytes_in": 0,
"bytes_out": 0,
"requests": 0
}
The tunnel URL will be https://{slug}.tunnel.nullbore.com (or https://tunnel.nullbore.com/t/{slug}). If the user has an account subdomain, it becomes https://{slug}.{account}.nullbore.com.
Errors
| Status | Error | Cause |
|---|---|---|
| 400 | invalid request body | Malformed JSON |
| 400 | local_port must be 1-65535 | Port out of range |
| 400 | invalid ttl format | Bad duration string |
| 400 | tunnel name must be... | Name validation failed |
| 409 | name already in use | Another tunnel has this name |
| 429 | rate limit exceeded | Too many creates (10/min/client) |
List tunnels
GET /v1/tunnels
Response
{
"tunnels": [
{
"id": "b1d0df1b-...",
"slug": "myapp",
"local_port": 3000,
"ttl": "1h0m0s",
"created_at": "2026-03-30T14:30:00Z",
"expires_at": "2026-03-30T15:30:00Z",
"bytes_in": 1024,
"bytes_out": 2048,
"requests": 42
}
]
}
Get a tunnel
GET /v1/tunnels/{id}
Returns the same tunnel object as create.
Close a tunnel
DELETE /v1/tunnels/{id}
Response
{"status": "closed"}
Extend a tunnel
PUT /v1/tunnels/{id}/extend
Request
{
"ttl": "4h"
}
Extends the tunnel's expiry by the given duration.
Rate limits
Tunnel creation is rate-limited to 10 per minute per client, with a burst of 5. Other endpoints are not rate-limited.
API: Health & Status
Health check
GET /health
No authentication required.
Response
{"status": "ok"}
Returns 200 OK when the server is running and ready to accept connections.
Use this for:
- Load balancer health checks
- Monitoring / uptime checks
- Deployment readiness probes
WebSocket Protocol
NullBore uses a WebSocket-based relay protocol inspired by bore and chisel. This page documents the wire protocol for client implementors.
Overview
┌─────────┐ HTTPS ┌──────────┐ Control WS ┌────────┐
│ Internet │────────────→│ Server │←────────────→│ Client │
│ Client │ │ (relay) │ Data WS │ │
└─────────┘ └──────────┘ └────────┘
↕
localhost:3000
Connection flow
- Client creates tunnel via
POST /v1/tunnels - Client opens control WebSocket:
GET /ws/control?tunnel_id={id} - Inbound HTTP request arrives at
/t/{slug},{slug}.tunnel.nullbore.com, or{name}.{account}.nullbore.com - Server hijacks the inbound connection and reconstructs the HTTP request bytes
- Server sends notification on control WS:
{"type":"connection","id":"<uuid>"} - Client opens data WebSocket:
GET /ws/data?id=<uuid> - Server pipes the inbound connection ↔ data WebSocket bidirectionally
Control channel
GET /ws/control?tunnel_id={tunnel_id}
Authorization: Bearer nbk_your_key
The control channel is a long-lived WebSocket. The server sends JSON messages:
Connection notification
{"type": "connection", "id": "550e8400-e29b-41d4-a716-446655440000"}
Sent when a new inbound request arrives. The client must open a data WebSocket with this id within 10 seconds, or the connection is dropped.
Keepalive
The server sends WebSocket ping frames every 30 seconds. The client must respond with pong within 60 seconds or the connection is considered dead.
Data channel
GET /ws/data?id={connection_id}
The data channel is a short-lived WebSocket for a single connection. Once opened:
- Server writes the reconstructed HTTP request bytes (method, path, headers, body)
- Bidirectional
io.Copystreams bytes between the inbound client and the data WebSocket - When either side closes, the other side closes too
Data flows as raw WebSocket binary messages — no JSON wrapping, no framing.
WSNetConn adapter
The server wraps websocket.Conn as a standard net.Conn via a WSNetConn adapter. This allows using standard Go io.Copy for the relay, with proper Close, Read, and Write semantics.
Timeouts
| Timeout | Duration | Description |
|---|---|---|
| Pending connection | 10s | Time for client to open data WS after notification |
| Ping interval | 30s | Server sends ping on control channel |
| Pong timeout | 60s | Client must respond to ping |
Server Setup
Run your own NullBore server. One binary, zero dependencies (besides SQLite).
Download
Pre-built binaries are on the releases page.
Or build from source:
git clone https://github.com/nullbore/nullbore-server.git
cd nullbore-server
CGO_ENABLED=1 go build -o nullbore-server ./cmd/server/
Note:
CGO_ENABLED=1is required for the SQLite dependency.
Quick start
# Generate an API key
API_KEY="nbk_$(openssl rand -hex 24)"
echo "Your API key: $API_KEY"
# Start the server
./nullbore-server \
--host 0.0.0.0 \
--port 8080 \
--api-keys "$API_KEY" \
--dash-password "your-dashboard-password"
The server is now running at http://localhost:8080 with:
- REST API at
/v1/tunnels - Dashboard at
/dash - Health check at
/health
Server flags
| Flag | Default | Description |
|---|---|---|
--host | 0.0.0.0 | Listen address |
--port | 8080 | Listen port |
--api-keys | (required) | Comma-separated API keys |
--db | nullbore.db | SQLite database path |
--dash-password | (none) | Dashboard passphrase (enables /dash) |
--base-domain | (none) | Base domain for subdomain routing (e.g., tunnel.example.com) |
--tls-domain | (none) | Domain for automatic TLS via Let's Encrypt |
--tls-email | (none) | Email for Let's Encrypt registration |
--tls-cache | certs | Directory for TLS certificate cache |
--max-ttl | 24h | Maximum tunnel TTL |
With TLS (recommended for production)
./nullbore-server \
--host 0.0.0.0 \
--port 443 \
--api-keys "$API_KEY" \
--tls-domain tunnel.yourdomain.com \
--tls-email [email protected] \
--base-domain tunnel.yourdomain.com \
--dash-password "your-dashboard-password"
See TLS & Certificates for full details.
DNS setup
For subdomain routing, you need:
A tunnel.yourdomain.com → your-server-ip
A *.tunnel.yourdomain.com → your-server-ip
The wildcard record enables myapp.tunnel.yourdomain.com style URLs.
Docker
docker run -d \
-p 443:443 \
-v nullbore-data:/data \
-e API_KEYS="$API_KEY" \
-e TLS_DOMAIN=tunnel.yourdomain.com \
-e [email protected] \
ghcr.io/nullbore/nullbore-server
What's next
TLS & Certificates
NullBore supports automatic TLS via Let's Encrypt (ACME) — no manual certificate management needed.
Automatic TLS
Start the server with --tls-domain and certificates are provisioned automatically:
./nullbore-server \
--port 443 \
--tls-domain tunnel.yourdomain.com \
--tls-email [email protected] \
--tls-cache /etc/nullbore/certs
On first request, NullBore:
- Requests a certificate from Let's Encrypt
- Completes the HTTP-01 challenge (port 80 must be reachable)
- Caches the certificate in
--tls-cachedirectory - Auto-renews before expiry
Per-subdomain certificates
When --base-domain is set, NullBore provisions individual certificates for each tunnel subdomain:
tunnel.yourdomain.com → cert provisioned on startup
myapp.tunnel.yourdomain.com → cert provisioned on first request
This uses HTTP-01 challenges, so each subdomain must resolve to your server's IP (wildcard DNS record).
Port 80
Let's Encrypt HTTP-01 challenges require port 80. NullBore automatically listens on port 80 for ACME challenges and redirects all other HTTP traffic to HTTPS.
Make sure your firewall allows ports 80 and 443:
ufw allow 80
ufw allow 443
Manual certificates
If you prefer to manage certificates yourself (e.g., behind a reverse proxy):
# Run without TLS, behind nginx/caddy
./nullbore-server --port 8080 --api-keys "$API_KEY"
Then configure your reverse proxy to handle TLS and forward to port 8080.
Certificate cache
Certificates are cached in the --tls-cache directory (default: ./certs). Back this directory up — losing it means re-provisioning all certificates, which is subject to Let's Encrypt rate limits.
Rate limits
Let's Encrypt has rate limits:
- 50 certificates per registered domain per week
- 5 duplicate certificates per week
For most deployments this is not an issue. If you expect hundreds of subdomains, consider using a wildcard certificate with DNS-01 challenges (requires DNS API access).
Dashboard
The NullBore server includes an embedded web dashboard for managing tunnels.
Enabling the dashboard
Pass --dash-password to enable it:
./nullbore-server --dash-password "your-secret-password" ...
The dashboard is available at /dash on your server.
Features
- Active tunnels: See all open tunnels with real-time status
- Traffic stats: Bytes in/out, request count per tunnel
- One-click close: Shut down any tunnel from the browser
- API key display: Copy your configured API key
Authentication
The self-hosted dashboard uses passphrase authentication. Enter the password you set with --dash-password to access it.
This is deliberately simple — the self-hosted dashboard is meant for single-user or small-team use. There are no user accounts or roles.
Security
- The dashboard is served over the same TLS connection as the API
- Session cookies are
HttpOnlyandSecure(when TLS is enabled) - The passphrase is compared in constant time
- No sensitive data is logged
Disabling the dashboard
Simply don't pass --dash-password. Without it, the /dash route returns 404.
Systemd Service
Run NullBore as a systemd service for automatic startup and restarts.
Service file
Create /etc/systemd/system/nullbore-server.service:
[Unit]
Description=NullBore Tunnel Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/nullbore-server \
--host 0.0.0.0 \
--port 443 \
--api-keys nbk_your_key_here \
--db /etc/nullbore/nullbore.db \
--tls-domain tunnel.yourdomain.com \
--tls-email [email protected] \
--tls-cache /etc/nullbore/certs \
--base-domain tunnel.yourdomain.com \
--dash-password "your-dashboard-password"
Restart=always
RestartSec=5
WorkingDirectory=/etc/nullbore
[Install]
WantedBy=multi-user.target
Setup
# Create data directory
mkdir -p /etc/nullbore
# Copy binary
cp nullbore-server /usr/local/bin/
chmod +x /usr/local/bin/nullbore-server
# Enable and start
systemctl daemon-reload
systemctl enable nullbore-server
systemctl start nullbore-server
# Check status
systemctl status nullbore-server
Logs
# Follow logs
journalctl -u nullbore-server -f
# Last 100 lines
journalctl -u nullbore-server -n 100
Graceful shutdown
NullBore handles SIGINT and SIGTERM gracefully — it stops accepting new connections and drains active ones (15-second timeout). Systemd sends SIGTERM on systemctl stop, so active tunnels get a clean shutdown.
Updates
# Stop, replace binary, restart
systemctl stop nullbore-server
cp nullbore-server-new /usr/local/bin/nullbore-server
systemctl start nullbore-server
The SQLite database and TLS certificate cache persist across restarts.
Docker Integration
Expose services running inside Docker containers through NullBore tunnels — without publishing ports to your host machine.
Why a separate container?
NullBore runs as a sidecar container on the same Docker network as your services. It connects outbound to the NullBore relay server (port 443) and forwards inbound tunnel traffic to your containers by hostname.
This means:
- Your services don't need published ports (
-pflags) - NullBore resolves container names via Docker's internal DNS
- Each project can have its own API key and tunnel config
- No changes to your existing containers
Quick start
1. Pull the image
docker pull ghcr.io/nullbore/tunnel:latest
The image is published for linux/amd64 and linux/arm64 (Raspberry Pi, Apple Silicon).
2. Create a dedicated API key
Go to your NullBore dashboard and create a new API key specifically for this Docker project. Using a separate key per project means:
- You can revoke access to one project without affecting others
- Device binding tracks each project independently
- Tunnel configs are scoped to the key
- Bandwidth usage is attributable
3. Add NullBore to your Compose file
services:
# Your existing service
webapp:
image: node:20-alpine
working_dir: /app
volumes:
- ./:/app
command: npm start
# No ports needed — NullBore handles external access
# NullBore sidecar
tunnel:
image: ghcr.io/nullbore/tunnel:latest
environment:
- NULLBORE_API_KEY=nbk_your_key_here
- NULLBORE_SERVER=https://tunnel.nullbore.com
- NULLBORE_TUNNELS=webapp:3000:my-webapp
depends_on:
- webapp
restart: unless-stopped
That's it. Run docker compose up and NullBore will expose webapp:3000 at https://my-webapp.yoursubdomain.nullbore.com.
Configuration
Environment variables
| Variable | Required | Description |
|---|---|---|
NULLBORE_API_KEY | ✅ | Your API key (from dashboard) |
NULLBORE_SERVER | No | Relay server URL (default: https://tunnel.nullbore.com) |
NULLBORE_TUNNELS | No | Static tunnel definitions (see below) |
Tunnel format
NULLBORE_TUNNELS accepts a comma-separated list of tunnels:
host:port:name,host:port:name,...
- host — container hostname (the service name in Compose)
- port — the port inside the container
- name — tunnel name (becomes the subdomain slug, requires Dev+ with a claimed subdomain)
Examples:
# Single service
NULLBORE_TUNNELS=webapp:3000:web
# Multiple services
NULLBORE_TUNNELS=api:8080:api,frontend:3000:app,postgres-admin:8081:db-admin
# Without names (random slugs, works on free tier)
NULLBORE_TUNNELS=webapp:3000,api:8080
Config file mode
For more control (TTL, auth, idle timeout), mount a config file:
services:
tunnel:
image: ghcr.io/nullbore/tunnel:latest
environment:
- NULLBORE_API_KEY=nbk_your_key_here
volumes:
- ./nullbore.toml:/home/nullbore/.config/nullbore/config.toml:ro
depends_on:
- webapp
# nullbore.toml
server = "https://tunnel.nullbore.com"
[[tunnels]]
name = "api"
host = "webapp"
port = 3000
ttl = "8h"
auth = "demo:s3cret"
[[tunnels]]
name = "docs"
host = "docs-server"
port = 4000
Dashboard-driven mode
If you omit NULLBORE_TUNNELS, the container runs in daemon mode and manages tunnels configured in your NullBore dashboard:
services:
tunnel:
image: ghcr.io/nullbore/tunnel:latest
environment:
- NULLBORE_API_KEY=nbk_your_key_here
Full example: multi-service project
# docker-compose.yml
services:
# Node.js API
api:
build: ./api
environment:
- DATABASE_URL=postgres://app:secret@db:5432/myapp
# React frontend
frontend:
build: ./frontend
# PostgreSQL
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=myapp
volumes:
- pgdata:/var/lib/postgresql/data
# pgAdmin for DB management
pgadmin:
image: dpage/pgadmin4
environment:
- [email protected]
- PGADMIN_DEFAULT_PASSWORD=admin
# NullBore — expose everything
tunnel:
image: ghcr.io/nullbore/tunnel:latest
environment:
- NULLBORE_API_KEY=nbk_your_project_key
- NULLBORE_TUNNELS=api:8080:api,frontend:3000:app,pgadmin:80:db-admin
depends_on:
- api
- frontend
- pgadmin
restart: unless-stopped
volumes:
pgdata:
docker compose up -d
# ✓ https://api.yourname.nullbore.com → api:8080
# ✓ https://app.yourname.nullbore.com → frontend:3000
# ✓ https://db-admin.yourname.nullbore.com → pgadmin:80
Tips
- One key per project — create a dedicated API key in the dashboard for each Docker project. This keeps tunnels isolated and revocable.
- Use
depends_on— ensure your services are running before NullBore tries to connect. restart: unless-stopped— NullBore reconnects automatically, but a container restart policy handles edge cases.- Protect admin tools — use
--auth(orauth = "user:pass"in config) for services like pgAdmin or database consoles. - TTL for dev sessions — set short TTLs in the config file to avoid leaving tunnels open overnight.
- ARM support — the image runs natively on Raspberry Pi and Apple Silicon (linux/arm64).
Networking notes
NullBore connects outbound to tunnel.nullbore.com:443 over WebSocket. No inbound ports need to be opened on your firewall or router. The tunnel container resolves other container names via Docker's built-in DNS — this works automatically within a Compose project (shared default network).
If your services are on a custom Docker network, make sure the NullBore container is on the same network:
services:
tunnel:
image: ghcr.io/nullbore/tunnel:latest
networks:
- backend
- frontend
environment:
- NULLBORE_API_KEY=nbk_your_key_here
- NULLBORE_TUNNELS=api:8080:api,web:3000:app
networks:
backend:
frontend:
MCP Servers & AI Agents
NullBore is a natural fit for exposing local MCP (Model Context Protocol) servers to cloud-hosted AI agents.
The problem
You have an MCP server running locally — maybe a file system tool, a database query tool, or a custom integration. Your AI agent runs in the cloud. It needs to reach your local MCP server, but your machine is behind NAT.
The solution
# Start your MCP server locally
my-mcp-server --port 4000
# Expose it with NullBore
nullbore open --port 4000 --ttl 2h --idle --name my-tools
# ✓ https://my-tools.yourname.nullbore.com → localhost:4000
Now point your cloud agent at https://my-tools.yourname.nullbore.com. When your session ends, the tunnel closes.
Why NullBore for MCP
- Temporary by design: MCP servers shouldn't be permanently exposed. NullBore tunnels auto-close.
- Idle TTL: Tunnel stays alive while the agent is working, closes after inactivity.
- HTTPS out of the box: Cloud agents typically require HTTPS endpoints.
- API-driven: Agents can open and close tunnels programmatically.
With OpenClaw
OpenClaw agents can manage NullBore tunnels directly via the REST API:
# Agent opens a tunnel to your local MCP server
curl -X POST https://tunnel.nullbore.com/v1/tunnels \
-H "Authorization: Bearer nbk_your_key" \
-d '{"local_port": 4000, "name": "agent-tools", "ttl": "1h", "idle_ttl": true}'
# Agent uses the tunnel
# ... work happens ...
# Agent closes the tunnel when done
curl -X DELETE https://tunnel.nullbore.com/v1/tunnels/{id} \
-H "Authorization: Bearer nbk_your_key"
Security considerations
- Use short TTLs or idle TTL mode — don't leave MCP servers exposed longer than necessary
- Each tunnel gets its own HTTPS certificate
- API key auth prevents unauthorized tunnel creation
- The tunnel relay never inspects payload content
OpenClaw Skill
NullBore ships an OpenClaw skill that lets AI agents manage tunnels programmatically.
Install the skill
Download the skill file and install it:
# Download from GitHub
curl -LO https://github.com/nullbore/nullbore-openclaw-skill/releases/latest/download/nullbore-openclaw-skill.skill
# Install (copy to your skills directory)
cp nullbore-openclaw-skill.skill ~/.openclaw/skills/
Configuration
Set these environment variables for OpenClaw:
NULLBORE_SERVER=https://tunnel.nullbore.com
NULLBORE_API_KEY=nbk_your_key_here
What the agent can do
Once installed, your OpenClaw agent can:
- Open tunnels: "Expose port 3000 for 2 hours"
- Open with idle TTL: "Expose my MCP server, keep it alive while there's traffic"
- List tunnels: "What tunnels are running?"
- Close tunnels: "Close the myapp tunnel"
- Extend tunnels: "Give the API tunnel another 4 hours"
- Clean up: "Close all my tunnels"
Example conversations
User: Expose port 4000 so my cloud agent can reach my local MCP server
Agent: Opens a tunnel with idle TTL:
curl -X POST https://tunnel.nullbore.com/v1/tunnels \
-H "Authorization: Bearer $NULLBORE_API_KEY" \
-d '{"local_port": 4000, "ttl": "1h", "idle_ttl": true}'
# → https://a7f3bc.tunnel.nullbore.com
User: What tunnels do I have running?
Agent: Lists active tunnels via GET /v1/tunnels and reports status, traffic, and expiry times.
Source
The skill source is open: github.com/nullbore/nullbore-openclaw-skill
The skill follows the OpenClaw skill format — a SKILL.md with API patterns and a references/api.md with the full endpoint reference.
Webhooks
Incoming webhooks (tunnel use case)
The most common use case: expose a local endpoint to receive webhooks from external services.
# Testing Stripe webhooks locally
nullbore open --port 3000 --name stripe-test --ttl 2h
# → https://stripe-test.yourname.nullbore.com
# Point Stripe's webhook URL at your tunnel
# Stripe → NullBore → localhost:3000/webhooks/stripe
Works with any service that sends webhooks: Stripe, GitHub, Twilio, Slack, Linear, etc.
Tips
- Use
--idlemode so the tunnel stays alive while you're iterating - Use a named tunnel so the URL is stable across reconnects
- The tunnel URL changes if you don't use a name (free tier)
Outgoing webhooks (NullBore events)
Dev+ plan feature — get notified when tunnel events happen.
NullBore can send webhook notifications when tunnels are created, about to expire, or closed.
Events
| Event | Description |
|---|---|
tunnel.created | A new tunnel was opened |
tunnel.expiring | Tunnel expires in 5 minutes |
tunnel.closed | Tunnel was closed (manually or by TTL) |
Payload
{
"event": "tunnel.created",
"timestamp": "2026-03-30T14:30:00Z",
"tunnel": {
"id": "b1d0df1b-...",
"slug": "myapp",
"local_port": 3000,
"ttl": "1h0m0s",
"expires_at": "2026-03-30T15:30:00Z"
}
}
Configuration
Configure webhook URLs in the dashboard under Settings → Webhooks.
Status: Outgoing webhooks are coming soon. The event system is designed, implementation is in progress.
CI/CD Pipelines
Use NullBore in CI/CD pipelines for preview deploys, integration testing, or temporary access to build artifacts.
Preview deploys
Open a tunnel in your CI pipeline to create a preview URL for each PR:
# GitHub Actions example
- name: Start preview server
run: |
npm start &
sleep 5
- name: Open tunnel
run: |
nullbore open --port 3000 --name "pr-${{ github.event.number }}" --ttl 4h
echo "Preview: https://pr-${{ github.event.number }}.yourname.nullbore.com"
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '🕳️ Preview: https://pr-${{ github.event.number }}.yourname.nullbore.com'
})
Integration testing
Expose a local test server for external integration tests:
- name: Start and expose test server
run: |
./test-server &
nullbore open --port 8080 --name "test-${{ github.run_id }}" --ttl 30m --idle
- name: Run integration tests
run: |
ENDPOINT="https://test-${{ github.run_id }}.yourname.nullbore.com" npm test
API-driven
For more control, use the REST API directly:
# Open
TUNNEL=$(curl -s -X POST https://tunnel.nullbore.com/v1/tunnels \
-H "Authorization: Bearer $NULLBORE_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"local_port\": 3000, \"name\": \"ci-${GITHUB_RUN_ID}\", \"ttl\": \"1h\"}")
TUNNEL_ID=$(echo $TUNNEL | jq -r .id)
TUNNEL_URL=$(echo $TUNNEL | jq -r '.slug')
echo "Tunnel: https://${TUNNEL_URL}.yourname.nullbore.com"
# ... run tests ...
# Close
curl -X DELETE "https://tunnel.nullbore.com/v1/tunnels/${TUNNEL_ID}" \
-H "Authorization: Bearer $NULLBORE_API_KEY"
Tips
- Use
--idlemode so tunnels don't expire during long test runs - Use unique names per run (
pr-123,ci-456) to avoid conflicts - Store your API key as a CI secret (
NULLBORE_API_KEY) - Set reasonable TTLs — CI tunnels shouldn't live forever
Webhook Testing
Test webhook integrations from services like Stripe, GitHub, Shopify, and Twilio against your local development server — no deployment needed.
The problem
Third-party services need a public URL to send webhooks. During development, your server is on localhost. You could deploy to a staging server every time you change a handler, or you could just open a tunnel.
Quick setup
# Your webhook handler is on port 3000
nullbore open --port 3000 --ttl 2h
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000
Now paste https://a7f3bc.tunnel.nullbore.com/webhooks/stripe into Stripe's webhook settings.
Stable URLs: Use
--name stripe-test(Dev+) to gethttps://stripe-test.yourname.nullbore.com— the same URL every time. Pro plans can also use a custom domain (stripe-test.tunnel.yourcompany.com).
With daemon mode
If you're testing webhooks regularly, add it to your config:
# ~/.config/nullbore/config.toml
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_key"
[[tunnels]]
name = "stripe-test"
port = 3000
nullbore daemon --detach
# Tunnel stays up, reconnects automatically
Inspect requests
Check what the webhook sender is actually sending:
nullbore requests stripe-test
# Shows recent request metadata: method, path, headers, body preview
You can also see request logs in the dashboard.
Tips
- Set a sensible TTL — 2 hours is usually enough for a testing session. Daemon mode auto-renews if you need it longer.
- Check signatures — services like Stripe and GitHub sign webhook payloads. Make sure your handler validates signatures even during testing.
- Use daemon mode for regular testing — avoids re-opening tunnels and updating webhook URLs every session.
Works great with
- Stripe webhooks
- GitHub webhooks (push, PR, release events)
- Shopify webhooks
- Twilio callbacks
- Slack event subscriptions
- Any service that sends HTTP callbacks
Client Demos
Show clients your work without deploying to a staging server. Share a live link to your local dev environment in seconds.
The scenario
You're building a web app. The client wants to see progress. You could push to staging, wait for CI, hope nothing breaks — or you could just share your local dev server.
One command
# Your app is running on port 3000
nullbore open --port 3000 --ttl 1h
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000
Send the link. They see exactly what you see. When the meeting's over, the tunnel expires.
Branded URLs: Use
--name demo(Dev+) to gethttps://demo.yourname.nullbore.com. Pro plans can also use a custom domain (demo.tunnel.yourcompany.com) — looks fully professional.
Why this works
- No deployment — show the latest code, not whatever was last pushed
- No staging environment — save the cost and complexity
- Time-limited — tunnel auto-closes, no permanent exposure
- HTTPS included — looks professional, works in all browsers
Multi-service demos
If your app has a frontend and API:
nullbore open -p 3000:frontend -p 8080:api
# ✓ https://frontend.yourname.nullbore.com → localhost:3000
# ✓ https://api.yourname.nullbore.com → localhost:8080
Note: Named tunnels require a Dev+ plan with a claimed account subdomain.
Tips
- Use
--ttlto match your meeting length — no zombie tunnels after you close your laptop. - Suspend/resume from the dashboard if you need to pause access mid-call.
MCP Server Access
Expose local MCP (Model Context Protocol) servers to cloud-hosted AI agents — temporarily and securely.
Why tunnels for MCP?
MCP servers typically run locally. Cloud-hosted AI agents (OpenClaw, Claude, custom GPT agents) need a URL to reach them. NullBore gives your MCP server a public HTTPS endpoint with zero config changes.
Expose a local MCP server
# Your MCP server runs on port 8001
nullbore open --port 8001 --ttl 4h
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:8001
Point your AI agent at https://a7f3bc.tunnel.nullbore.com/mcp and it can call your local tools.
Stable URLs: Use
--name my-mcp(Dev+) to gethttps://my-mcp.yourname.nullbore.com— configure once in your agent settings. Pro plans can also use a custom domain (mcp.tunnel.yourcompany.com).
Always-on with daemon
For persistent MCP server access:
# ~/.config/nullbore/config.toml
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_key"
[[tunnels]]
name = "my-mcp"
port = 8001
nullbore daemon --detach
# MCP server stays reachable, reconnects on network blips
With OpenClaw
If you're using OpenClaw, your agent can open tunnels programmatically via the NullBore API — expose tools when needed, close them when done. See the OpenClaw integration guide for details.
Security considerations
- Use TTLs — don't leave MCP servers exposed permanently unless you need to.
- API key per device — each machine running an MCP server should have its own NullBore API key.
- Use
--auth— protect your MCP endpoint with basic auth (nullbore open 8001 --auth agent:secret). The agent can include the credentials in its URL. - Auth on your MCP server — for additional security, your MCP server can also validate requests independently.
Remote Home Services
Access services running on your home network from anywhere — without opening ports on your router or setting up a VPN.
The problem
You're running Home Assistant, a NAS, Plex, Pi-hole, or a dev server at home. You want to reach it from your phone, office, or while traveling. Port forwarding is fragile, ISPs block it, and dynamic DNS is a hassle.
Daemon mode
NullBore's daemon keeps tunnels open permanently with auto-reconnect — perfect for always-on home services.
# ~/.config/nullbore/config.toml
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_key"
[[tunnels]]
name = "homeassistant"
port = 8123
[[tunnels]]
name = "nas"
port = 5000
nullbore daemon --detach
# Both services are now reachable from anywhere
# https://homeassistant.yourname.nullbore.com
# https://nas.yourname.nullbore.com
Named tunnels require a Dev plan ($7/mo) or higher with a claimed account subdomain. Free tier tunnels use random slugs and have a 2-hour TTL — not ideal for always-on services.
On a Raspberry Pi
# Install
curl -fsSL nullbore.com/install.sh | sh
# Set up config
mkdir -p ~/.config/nullbore
cat > ~/.config/nullbore/config.toml << EOF
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_key"
[[tunnels]]
name = "pi-web"
port = 80
EOF
# Run as a service
nullbore daemon --detach
Security
- Basic auth — add
auth = "user:pass"to any tunnel config for password protection before traffic reaches your service. - TLS everywhere — all traffic is encrypted, even if the local service is HTTP.
- TTL caps — Dev tier tunnels are persistent. Pro tier has no TTL limit.
- Suspend from anywhere — use the dashboard to instantly cut access if needed.
- One key per device — if a Pi is compromised, revoke just that key.
Works great with
- Home Assistant
- Synology / TrueNAS
- Pi-hole / AdGuard
- Plex / Jellyfin
- Node-RED
- Any HTTP service behind NAT
CI/CD Preview Deploys
Give every pull request a live preview URL — without provisioning infrastructure.
How it works
In your CI pipeline, install NullBore, start your app, open a tunnel, and post the URL as a PR comment. Reviewers click the link and see a live version of the branch.
GitHub Actions example
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install NullBore
run: curl -fsSL nullbore.com/install.sh | sh
- name: Start app
run: |
npm ci
npm start &
sleep 5 # wait for server to boot
- name: Open tunnel
env:
NULLBORE_API_KEY: ${{ secrets.NULLBORE_API_KEY }}
run: |
export PATH="$PATH:$HOME/.local/bin"
nullbore open --port 3000 --name pr-${{ github.event.number }} --ttl 2h
echo "PREVIEW_URL=https://pr-${{ github.event.number }}.yourname.nullbore.com" >> $GITHUB_ENV
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `🔗 Preview: ${process.env.PREVIEW_URL}\n\n_Expires in 2 hours._`
})
Named tunnels (like
pr-123) require a Dev plan ($7/mo) or higher. On the free tier, use random slugs and capture the URL fromnullbore openoutput instead.
Why this over alternatives
- No infrastructure to manage — no Vercel/Netlify lock-in, no extra cloud resources
- Works with any stack — if it runs on localhost, NullBore can expose it
- Time-limited — preview URLs auto-expire, no orphaned deployments
- Self-hostable — run your own NullBore server for fully internal previews
Tips
- Use
pr-$NUMBERas the tunnel name for stable, predictable URLs. - Set TTL to match your review cycle (2-4 hours is typical).
- Add a cleanup step that runs
nullbore closeon PR merge/close.
Bot-Triggered Exposure
Let scripts, bots, and AI agents open and close tunnels programmatically via the REST API.
The idea
Sometimes you don't want a human to open a tunnel — you want automation to do it. A deploy bot, a monitoring script, or an AI agent decides when to expose a service and for how long.
API-driven tunnels
# Open a tunnel via API
curl -X PUT https://tunnel.nullbore.com/api/tunnels \
-H "Authorization: Bearer nbk_your_key" \
-H "Content-Type: application/json" \
-d '{"port": 3000, "name": "bot-service", "ttl": "1h"}'
# Close it when done
curl -X DELETE https://tunnel.nullbore.com/api/tunnels/bot-service \
-H "Authorization: Bearer nbk_your_key"
Named tunnels (like
bot-service) require a Dev plan ($7/mo) or higher. Free tier tunnels get a random slug.
ChatOps example
Build a Slack/Discord bot that responds to /expose 3000:
# Pseudocode — your bot handler
@bot.command("expose")
def expose(port):
# Start local service if needed
subprocess.run(["nullbore", "open", "--port", str(port), "--ttl", "30m"])
return f"Tunnel open for 30 minutes"
AI agent integration
AI agents can use the NullBore API as a tool:
- Expose a service when a user asks "let me access your dev server"
- Set appropriate TTL based on the context
- Close the tunnel when the task is complete
- Report the URL back to the user
See the MCP Servers & AI Agents and OpenClaw integration docs for more details.
Security
- API keys have tier limits — bots can't exceed your plan's tunnel count.
- TTL is mandatory — every tunnel expires. Bots can't create permanent exposure by accident.
- Audit trail — the dashboard shows who created each tunnel and when.
Quick File Sharing
Share files from your machine with a public URL — no cloud upload needed.
The simplest approach
Python's built-in HTTP server + NullBore = instant file sharing.
# Serve the current directory
python3 -m http.server 8000 &
# Expose it for 30 minutes
nullbore open --port 8000 --ttl 30m
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:8000
Send the link. They can browse and download files. After 30 minutes, the tunnel closes and the files are no longer accessible.
Share a specific directory
# Share just your build output
cd ./dist
python3 -m http.server 8000 &
nullbore open --port 8000 --ttl 1h
With Node.js
npx serve ./my-folder -l 8000 &
nullbore open --port 8000 --ttl 1h
With basic authentication
NullBore has built-in basic auth — pass --auth user:pass and the tunnel enforces it before relaying any requests to your local service:
# Serve the current directory
python3 -m http.server 8000 &
# Expose it with password protection
nullbore open --port 8000 --ttl 1h --auth alice:s3cret
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:8000 [auth protected]
The browser (or HTTP client) will be challenged with a 401 before any request reaches your machine. The Authorization header is stripped before forwarding, so your local service never sees it.
You can also set auth in ~/.config/nullbore/config.toml for a persistent daemon tunnel:
[[tunnels]]
name = "files"
port = 8000
ttl = "24h"
auth = "alice:s3cret"
Why not just use cloud storage?
- No upload step — files are served directly from your machine
- No account needed (for the recipient) — just a URL
- Time-limited — the link expires automatically
- Large files — no upload size limits from cloud providers
- Private by default — random slug URL, TTL expiry, no indexing
Tips
- Short TTL — 30 minutes to 1 hour is usually enough for a file transfer.
- Be mindful of what you expose —
python3 -m http.serverserves everything in the directory. Don't run it in~. - For sensitive files — use
--auth user:pass. It takes one flag and requires no changes to your local server.