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.tunnel.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
# macOS / Linux
curl -sSL https://nullbore.com/install.sh | sh
# Or download directly from GitHub releases
# https://github.com/nullbore/nullbore-client/releases
2. Configure
# Set your server and API key
nullbore config set server https://tunnel.nullbore.com
nullbore config set api-key YOUR_API_KEY
Get your API key from the dashboard (hosted) or your self-hosted dashboard.
Configuration is stored in ~/.nullbore/config.toml.
3. Open a tunnel
# Expose localhost:3000
nullbore open --port 3000
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000
# With a custom name (Hobby+ plans)
nullbore open --port 3000 --name myapp
# ✓ https://myapp.tunnel.nullbore.com → localhost:3000
# With a 30-minute TTL
nullbore open --port 3000 --ttl 30m
# Keep alive while there's traffic (idle timeout)
nullbore open --port 3000 --ttl 30m --idle
4. 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.
5. Close it
# Close by name
nullbore close myapp
# Close by ID
nullbore close a7f3bc
# Or just wait — it closes itself when the TTL expires
Multiple tunnels
# Open several at once
nullbore open -p 3000:api -p 8080:web -p 5432:db
What's next?
- Configuration reference — all config options
- Managing tunnels — list, close, extend
- 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 ~/.nullbore/config.toml.
Config file
# ~/.nullbore/config.toml
server = "https://tunnel.nullbore.com"
api_key = "nbk_your_api_key_here"
Setting values
nullbore config set server https://tunnel.nullbore.com
nullbore config set api-key nbk_your_api_key_here
Environment variables
Environment variables override config file values:
| Variable | Description |
|---|---|
NULLBORE_SERVER | Server URL |
NULLBORE_API_KEY | API key for authentication |
CLI flags
CLI flags override both config file and environment variables:
nullbore open --port 3000 --server https://my-server.com --api-key nbk_xxx
Precedence
CLI flags > Environment variables > Config file > Defaults
Defaults
| Setting | Default |
|---|---|
server | https://tunnel.nullbore.com |
ttl | 1h |
idle | false |
Opening Tunnels
Basic usage
# Expose a local port
nullbore open --port 3000
# ✓ https://a7f3bc.tunnel.nullbore.com → localhost:3000
This creates a tunnel with a random URL and a 1-hour TTL.
Named tunnels
On Hobby plans and above, you can choose your URL:
nullbore open --port 3000 --name myapp
# ✓ https://myapp.tunnel.nullbore.com → localhost:3000
Names must be 2-63 characters, lowercase alphanumeric and hyphens only. Names are first-come-first-served and tied to your account while the tunnel is active.
TTL (time to live)
Every tunnel has a TTL — the maximum time it stays open.
# 30 minutes
nullbore open --port 3000 --ttl 30m
# 4 hours
nullbore open --port 3000 --ttl 4h
# 7 days (Hobby plan max)
nullbore open --port 3000 --ttl 168h
TTL limits depend on your plan:
| Plan | Max TTL |
|---|---|
| Free | 2 hours |
| Hobby | 7 days |
| 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 --port 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
Multiple tunnels
Open several tunnels at once with the -p flag:
nullbore open -p 3000:api -p 8080:web -p 5432:db
# ✓ https://api.tunnel.nullbore.com → localhost:3000
# ✓ https://web.tunnel.nullbore.com → localhost:8080
# ✓ https://db.tunnel.nullbore.com → localhost:5432
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}'
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.tunnel.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.tunnel.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
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
Plan limits
Idle TTL mode still respects your plan's maximum TTL. On the free plan, the tunnel will close after 2 hours of continuous activity regardless of idle TTL. On Pro, there is no maximum — the tunnel can stay alive indefinitely as long as there's periodic traffic.
| Plan | Max idle TTL |
|---|---|
| Free | 2 hours |
| Hobby | 7 days |
| Pro | Unlimited |
Subdomain Routing
Every tunnel gets a unique subdomain under tunnel.nullbore.com.
How it works
When you open a tunnel, NullBore assigns it a subdomain:
# Random (free tier)
nullbore open --port 3000
# → https://a7f3bc.tunnel.nullbore.com
# Named (Hobby+)
nullbore open --port 3000 --name myapp
# → https://myapp.tunnel.nullbore.com
Each subdomain gets its own TLS certificate automatically via Let's Encrypt.
Path-based fallback
Tunnels are also accessible via path-based URLs:
https://tunnel.nullbore.com/t/a7f3bc
https://tunnel.nullbore.com/t/myapp
Subdomain routing is preferred — it gives each tunnel full isolation (cookies, CORS, etc). Path-based URLs are a fallback for environments where wildcard DNS isn't configured.
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 tunnel.yourdomain.com
The server automatically provisions TLS certificates for each subdomain on first request.
nullbore-server \
--host 0.0.0.0 \
--port 443 \
--base-domain tunnel.yourdomain.com \
--tls-domain tunnel.yourdomain.com \
--tls-email admin@yourdomain.com
Custom Domains
Pro plan feature — bring your own domain for tunnel URLs.
Custom domains let you use your own domain instead of *.tunnel.nullbore.com.
Setup
-
Add a CNAME record pointing your domain to NullBore:
tunnel.yourcompany.com → tunnel.nullbore.com -
Configure the domain in your dashboard or via API (coming soon)
-
Open tunnels with your domain:
nullbore open --port 3000 --domain tunnel.yourcompany.com
NullBore automatically provisions a TLS certificate for your domain via Let's Encrypt.
Self-hosted
On a self-hosted server, custom domains work by adding the domain to your server's TLS configuration:
nullbore-server \
--tls-domain tunnel.yourdomain.com,tunnel.yourcompany.com
Status: Custom domain support is coming soon. The infrastructure is in place (per-domain ACME), but the dashboard integration and client flags are in development.
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 |
| Hobby | 3 |
| 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 (Hobby+ 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}).
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}or{slug}.tunnel.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 admin@yourdomain.com \
--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 TLS_EMAIL=admin@yourdomain.com \
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 admin@yourdomain.com \
--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 admin@yourdomain.com \
--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.
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.tunnel.nullbore.com → localhost:4000
Now point your cloud agent at https://my-tools.tunnel.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.tunnel.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)
Hobby+ 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 }}.tunnel.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 }}.tunnel.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 }}.tunnel.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}.tunnel.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