Self-Hosted Install Guide

v1.1.0+ — Estimated time: 15–30 minutes

1. Prerequisites

Before starting, you need:

A Linux server with:

  • Docker 24+ and Docker Compose v2 (docker compose, not docker-compose)
  • Ports 80 and 443 open on the server's firewall
  • At least 512 MB RAM and 5 GB disk space

A domain you control, for example your.domain.com. You will configure a wildcard DNS record pointing to this server.

A wildcard TLS certificate covering *.your.domain.com. You need both the full chain certificate and the private key as separate files. See section 4 for how to obtain one.

What you do NOT need:

  • A local PostgreSQL installation (bundled in Docker Compose)
  • Any runtime dependencies beyond Docker and Caddy

2. Quick Start

Clone the server repository and configure your environment:

bash
git clone https://github.com/sqoia-dev/tunnl.git
cd tunnl
cp .env.example .env

Open .env in your editor and set the required values:

.env
# Required — set these before starting
DOMAIN=your.domain.com             # Your domain, no https:// prefix
JWT_SECRET=<random-64-char-string> # Generate with: openssl rand -hex 32
DB_PASSWORD=<secure-password>      # PostgreSQL password for the bundled DB
SELF_HOSTED_MODE=true              # Enables self-hosted defaults (Pro limits for all users, no Stripe warnings)

# Optional — defaults are fine for most setups
PORT=8080
MAX_TUNNELS=25
REQUESTS_PER_HOUR=50000
📋
Data retention is hardcoded at 30 days. Request logs older than 30 days are automatically purged. This is not currently configurable via environment variable.

To generate a secure JWT_SECRET:

bash
openssl rand -hex 32

Start the server stack:

bash
docker compose up -d

This starts two containers:

  • tunnl — the Go application, listening on port 8080 (localhost only)
  • db — PostgreSQL database (localhost only, not exposed externally)

Caddy runs separately on the host as your reverse proxy and TLS termination layer. It is not managed by Docker Compose.

Verify both containers are running:

bash
docker compose ps

Both should show Up. Check the server health:

bash
curl http://localhost:8080/health
# {"status":"healthy","checks":{"database":"ok"}}
📋
If the database check fails, wait 10–15 seconds and try again. PostgreSQL takes a moment to initialize on first start.

3. Configure DNS

tunnl uses wildcard subdomains to route each tunnel connection. Every tunnel gets its own subdomain: abc123.your.domain.com, xyz789.your.domain.com, and so on.

You need two DNS records pointing to your server's public IP address:

TypeNameValueTTL
Ayour.domain.com<your server IP>300
A*.your.domain.com<your server IP>300
⚠️
The wildcard record (*.your.domain.com) is required. Without it, tunnel subdomain requests will not resolve.

To find your server's public IP:

bash
curl -s https://checkip.amazonaws.com

To check DNS propagation:

bash
dig your.domain.com
dig anything.your.domain.com
# Both should return your server's IP

4. Configure TLS

Caddy needs your wildcard TLS certificate and private key to serve HTTPS on *.your.domain.com.

Obtaining a Wildcard Certificate

Wildcard certificates require a DNS challenge for validation. The recommended approach is Let's Encrypt via certbot with a DNS plugin.

Example with Cloudflare:

bash
pip install certbot certbot-dns-cloudflare
certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
  -d "your.domain.com" \
  -d "*.your.domain.com"

This produces:

  • Certificate + chain: /etc/letsencrypt/live/your.domain.com/fullchain.pem
  • Private key: /etc/letsencrypt/live/your.domain.com/privkey.pem

Certbot plugins exist for most major DNS providers: Route 53, Cloudflare, DigitalOcean, Namecheap, Google Cloud DNS, and others.

Configuring Caddy with the Certificate

Place the certificate files on the host and reference them in your Caddyfile. The tunnl-server handles TLS entirely through Caddy — there are no TLS_CERT_FILE or TLS_KEY_FILE environment variables in the server.

bash
mkdir -p /etc/ssl/tunnl
cp /path/to/fullchain.pem /etc/ssl/tunnl/fullchain.pem
cp /path/to/privkey.pem /etc/ssl/tunnl/privkey.pem
chmod 600 /etc/ssl/tunnl/privkey.pem

Reference the certificate files in your Caddyfile:

Caddyfile
your.domain.com, *.your.domain.com {
    tls /etc/ssl/tunnl/fullchain.pem /etc/ssl/tunnl/privkey.pem
    reverse_proxy localhost:8080
}

Reload Caddy to apply the certificate:

bash
caddy reload --config /etc/caddy/Caddyfile

Verify TLS is working:

bash
curl -I https://your.domain.com/health
# HTTP/2 200

Certificate Renewal

Let's Encrypt certificates expire every 90 days. Set up automatic renewal. Because Caddy runs on the host (not in Docker Compose), reload it directly via systemd or caddy reload:

crontab
# Add to root crontab
0 3 * * * certbot renew --quiet && caddy reload --config /etc/caddy/Caddyfile

5. Connect the CLI

Install the tunnl CLI on your development machine.

Linux/macOS:

bash
curl -sSL https://tunnl.sh/install.sh | sh

Or download a binary directly from the GitHub releases page.

Verify installation:

bash
tunnl version

First, create an account in the web dashboard at https://your.domain.com. Then log in with the CLI:

bash
tunnl login --server https://your.domain.com

The CLI prompts for your email and password at the terminal. Enter your credentials — the password input is hidden. The JWT is saved to ~/.tunnl/config.json.

📋
Running on a headless server with no local browser? Use tunnl login --server https://your.domain.com --device to authenticate via device flow (RFC 8628): the CLI prints a URL and code you can complete on any device.

Verify you are logged in:

bash
tunnl whoami
# you@example.com

6. Create Your First Tunnel

Start a tunnel to your local server on port 3000:

bash
tunnl --port 3000

The output looks like:

output
tunnl v1.1.0

  Tunnel URL:  https://abc123.your.domain.com
  Forwarding:  localhost:3000

  Status:      connected
  Press Ctrl+C to stop

Waiting for requests...

Send a test request:

bash
curl https://abc123.your.domain.com/webhook -X POST \
  -H "Content-Type: application/json" \
  -d '{"event":"test","payload":"hello"}'

Open the TUI inspector in another terminal:

bash
tunnl inspect

The inspector shows the live request stream. Select any request to view full headers and body. Press r to replay it.

The web dashboard is available at https://your.domain.com in your browser.

7. Verify the Setup

Run through this checklist after creating your first tunnel:

  • tunnl --port 3000 shows a URL on *.your.domain.com
  • Sending a request to the tunnel URL reaches your local port 3000
  • The request appears in tunnl inspect in real time
  • The request appears in the web dashboard at https://your.domain.com
  • Stopping and restarting the server (docker compose restart tunnl) preserves request history
  • After restart, tunnl inspect shows the same history
If the last two items work, persistent history is functioning correctly. This is the core property that separates tunnl from in-memory inspectors.

8. Configuration Reference

All configuration is via environment variables in .env. The server reads these on startup. See the full Configuration Reference for all options.

VariableRequiredDescription
DOMAINRequiredBase domain. Tunnel URLs are <subdomain>.<DOMAIN>. No https://.
JWT_SECRETRequiredSecret for signing auth tokens. At least 32 characters.
DB_PASSWORDRequiredPassword for the bundled PostgreSQL container.
SELF_HOSTED_MODEOptionalSet to true to enable self-hosted defaults: Pro-level limits for all users, Stripe warnings suppressed.
DATABASE_URLOptionalPostgreSQL connection string. Defaults to the bundled container.
MAX_TUNNELSOptionalMax concurrent tunnels per user. Default: 5.
REQUESTS_PER_HOUROptionalRate limit per tunnel, sliding window. Default: 100.
📋
Data retention is hardcoded at 30 days and is not configurable via environment variable. Request logs older than 30 days are automatically purged.

9. Upgrading

To upgrade to a new version, pull the new Docker image and restart:

bash
cd /path/to/tunnl
git pull origin main
docker compose pull
docker compose up -d

Database migrations run automatically on startup. Already-applied migrations are skipped. To validate pending migrations before upgrading:

bash
docker compose run --rm tunnl --dry-run-migrations

To upgrade only the CLI:

bash
tunnl update

10. Troubleshooting

DNS is not resolving

Symptom: tunnl --port 3000 returns a URL but sending a request times out.

Check: Run dig @8.8.8.8 abc123.your.domain.com from outside your server. It should return your server's IP.

Fix: Verify the wildcard A record (*.your.domain.com) is set in your DNS dashboard. Wait for propagation (TTL minutes, up to 48 hours for cold cache).

Certificate errors

Symptom: Browser shows a certificate warning. CLI shows a TLS handshake error.

Verify the certificate covers *.your.domain.com:

bash
openssl x509 -in /etc/ssl/tunnl/fullchain.pem -noout -text | grep "DNS:"
# Should include: DNS:*.your.domain.com

Verify the private key matches the certificate:

bash
openssl x509 -noout -modulus -in /etc/ssl/tunnl/fullchain.pem | md5sum
openssl rsa -noout -modulus -in /etc/ssl/tunnl/privkey.pem | md5sum
# Both lines must produce the same hash

Port 80 or 443 already in use

Symptom: docker compose up fails with bind: address already in use.

bash
sudo lsof -i :80
sudo lsof -i :443

Stop the conflicting service (common: nginx, Apache, another Caddy instance).

Requests arrive but do not reach localhost

Symptom: Requests appear in the dashboard but your local process does not receive them.

Verify your local process is listening on the port you gave to tunnl:

bash
lsof -i :3000   # verify something is listening

"Subdomain already in use" error

Cause: A previous tunnel session with the same subdomain is still registered. This can happen if the CLI crashed without cleanly disconnecting.

Fix: Wait 30–60 seconds for the session to time out, or log in to the web dashboard and manually disconnect the existing session.

Server starts but no tunnels can connect

Verify the WebSocket endpoint is reachable:

bash
curl -I https://your.domain.com/ws
# Should return 400 (upgrade required), not a TLS error or 502

Verify port 443 is open on your server's firewall. The WebSocket tunnel uses HTTPS port 443 — there is no separate WebSocket port to open.