Self-Hosted Install Guide
1. Prerequisites
Before starting, you need:
A Linux server with:
- Docker 24+ and Docker Compose v2 (
docker compose, notdocker-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:
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:
# 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
To generate a secure JWT_SECRET:
openssl rand -hex 32
Start the server stack:
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:
docker compose ps
Both should show Up. Check the server health:
curl http://localhost:8080/health
# {"status":"healthy","checks":{"database":"ok"}}
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:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | your.domain.com | <your server IP> | 300 |
| A | *.your.domain.com | <your server IP> | 300 |
*.your.domain.com) is required. Without it, tunnel subdomain requests will not resolve.To find your server's public IP:
curl -s https://checkip.amazonaws.com
To check DNS propagation:
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:
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.
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:
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:
caddy reload --config /etc/caddy/Caddyfile
Verify TLS is working:
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:
# 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:
curl -sSL https://tunnl.sh/install.sh | sh
Or download a binary directly from the GitHub releases page.
Verify installation:
tunnl version
First, create an account in the web dashboard at https://your.domain.com. Then log in with the CLI:
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.
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:
tunnl whoami
# you@example.com
6. Create Your First Tunnel
Start a tunnel to your local server on port 3000:
tunnl --port 3000
The output looks like:
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:
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:
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 3000shows a URL on*.your.domain.com- Sending a request to the tunnel URL reaches your local port 3000
- The request appears in
tunnl inspectin 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 inspectshows the same history
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.
| Variable | Required | Description |
|---|---|---|
DOMAIN | Required | Base domain. Tunnel URLs are <subdomain>.<DOMAIN>. No https://. |
JWT_SECRET | Required | Secret for signing auth tokens. At least 32 characters. |
DB_PASSWORD | Required | Password for the bundled PostgreSQL container. |
SELF_HOSTED_MODE | Optional | Set to true to enable self-hosted defaults: Pro-level limits for all users, Stripe warnings suppressed. |
DATABASE_URL | Optional | PostgreSQL connection string. Defaults to the bundled container. |
MAX_TUNNELS | Optional | Max concurrent tunnels per user. Default: 5. |
REQUESTS_PER_HOUR | Optional | Rate limit per tunnel, sliding window. Default: 100. |
9. Upgrading
To upgrade to a new version, pull the new Docker image and restart:
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:
docker compose run --rm tunnl --dry-run-migrations
To upgrade only the CLI:
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:
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:
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.
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:
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:
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.