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 routingyourapp.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?

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.

PlatformBinary
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:

VariableDescription
NULLBORE_SERVERServer URL
NULLBORE_API_KEYAPI 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

SettingDefault
serverhttps://tunnel.nullbore.com
ttl1h
idlefalse

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:

PlanMax TTL
Free2 hours
Hobby7 days
ProUnlimited (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:

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.

PlanMax idle TTL
Free2 hours
Hobby7 days
ProUnlimited

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:

  1. Configure a wildcard DNS record: *.tunnel.yourdomain.com → your-server-ip
  2. 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

  1. Add a CNAME record pointing your domain to NullBore:

    tunnel.yourcompany.com → tunnel.nullbore.com
    
  2. Configure the domain in your dashboard or via API (coming soon)

  3. 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

PlanAPI Keys
Free1
Hobby3
Pro10

Unauthenticated endpoints

These endpoints don't require authentication:

  • GET /health — server health check
  • GET /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
}
FieldTypeRequiredDescription
local_portintyesLocal port to tunnel (1-65535)
namestringnoCustom subdomain name (Hobby+ plans). 2-63 chars, lowercase alphanumeric + hyphens.
ttlstringnoTime to live (Go duration: 30m, 4h, 168h). Default: 1h.
idle_ttlboolnoIf 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

StatusErrorCause
400invalid request bodyMalformed JSON
400local_port must be 1-65535Port out of range
400invalid ttl formatBad duration string
400tunnel name must be...Name validation failed
409name already in useAnother tunnel has this name
429rate limit exceededToo 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

  1. Client creates tunnel via POST /v1/tunnels
  2. Client opens control WebSocket: GET /ws/control?tunnel_id={id}
  3. Inbound HTTP request arrives at /t/{slug} or {slug}.tunnel.nullbore.com
  4. Server hijacks the inbound connection and reconstructs the HTTP request bytes
  5. Server sends notification on control WS: {"type":"connection","id":"<uuid>"}
  6. Client opens data WebSocket: GET /ws/data?id=<uuid>
  7. 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:

  1. Server writes the reconstructed HTTP request bytes (method, path, headers, body)
  2. Bidirectional io.Copy streams bytes between the inbound client and the data WebSocket
  3. 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

TimeoutDurationDescription
Pending connection10sTime for client to open data WS after notification
Ping interval30sServer sends ping on control channel
Pong timeout60sClient 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=1 is 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

FlagDefaultDescription
--host0.0.0.0Listen address
--port8080Listen port
--api-keys(required)Comma-separated API keys
--dbnullbore.dbSQLite 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-cachecertsDirectory for TLS certificate cache
--max-ttl24hMaximum tunnel TTL
./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:

  1. Requests a certificate from Let's Encrypt
  2. Completes the HTTP-01 challenge (port 80 must be reachable)
  3. Caches the certificate in --tls-cache directory
  4. 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 HttpOnly and Secure (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 --idle mode 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

EventDescription
tunnel.createdA new tunnel was opened
tunnel.expiringTunnel expires in 5 minutes
tunnel.closedTunnel 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 --idle mode 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