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

  1. Sign up at nullbore.com
  2. Go to the dashboard
  3. 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?

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 ~/.config/nullbore/config.toml.

Legacy path ~/.nullbore/config.toml is 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 export when 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.

VariableDescriptionDefault
NULLBORE_SERVERTunnel server URL (must include https://)https://tunnel.nullbore.com
NULLBORE_API_KEYAPI key for authentication
NULLBORE_DASHBOARDDashboard URL (for daemon mode)https://nullbore.com
NULLBORE_TLS_SKIP_VERIFYSkip TLS certificate verification (set to 1 or true)
NULLBORE_TUNNELSStatic tunnel list for Docker/headless mode (format: host:port:slug,...)
NULLBORE_INSTALL_DIROverride install directory for install.sh~/.local/bin
NULLBORE_VERSIONPin a specific version for install.sh

Important: Use export when setting environment variables in your shell. Without export, the variable is only a shell variable and won't be passed to nullbore.

# 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.toml is 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:

PlanMax TTL
Free2 hours
DevPersistent
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 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:

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:

PlanURL formatExample
Free{random}.tunnel.nullbore.coma7f3bc.tunnel.nullbore.com
Dev{name}.{you}.nullbore.comapi.heroapp.nullbore.com
Pro{name}.tunnel.yourdomain.comapi.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:

  1. A Dev or Pro plan
  2. 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

  1. Go to Dashboard → Account Subdomain
  2. Claim a subdomain (e.g. heroapp) — takes ~2 minutes to provision a TLS certificate
  3. 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:

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

PlanAPI Keys
Free1
Dev5
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 (Dev+ 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}). If the user has an account subdomain, it becomes https://{slug}.{account}.nullbore.com.

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}, {slug}.tunnel.nullbore.com, or {name}.{account}.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 [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:

  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 [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 (-p flags)
  • 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

VariableRequiredDescription
NULLBORE_API_KEYYour API key (from dashboard)
NULLBORE_SERVERNoRelay server URL (default: https://tunnel.nullbore.com)
NULLBORE_TUNNELSNoStatic 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 (or auth = "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 --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)

Dev+ 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 }}.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 --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

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 get https://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 get https://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 --ttl to 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 get https://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 from nullbore open output 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-$NUMBER as 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 close on 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 exposepython3 -m http.server serves 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.