Dead-simple tunneling with random 3-word subdomains. Self-hosted alternative to ngrok.
relay 3000
# π Relay active!
# https://quiet-snow-lamp.tunnel.example.com
# β http://localhost:3000- π² Random 3-word subdomains -
agent-urge-dare.tunnel.example.com - π Dead simple -
relay 3000and done - π Secret-based auth - No complex OAuth flows
- π³ Docker native - Add to your compose file
- π Auto-reconnect - Handles network issues gracefully
- π Self-hosted - Your infrastructure, your control
- π¦ Single binary - Server and client in one package
- πͺΆ Lightweight - Only 1 dependency (
ws), uses native Node.js APIs
npm install -g @talyuk/relay
# Quick start with flags
relay 3000 --server tunnel.example.com --secret your-secret
# Or with env vars
export SERVER=tunnel.example.com
export SECRET=your-secret
relay 3000
# Custom subdomain (persistent URL)
relay 3000 --subdomain myapp
# β https://myapp.tunnel.example.com (always the same!)docker run -e SERVER=tunnel.example.com \
-e SECRET=your-secret \
talyuk/relay 3000Add to your docker-compose.dev.yml:
services:
app:
build: .
ports:
- "3000:3000"
relay:
image: talyuk/relay
command: app:3000
environment:
SERVER: tunnel.example.com
SECRET: ${SECRET}
depends_on:
- appThen create .env:
SECRET=your-team-secretRun:
docker compose -f docker-compose.dev.yml up# Expose localhost port (random subdomain)
relay 3000
# With server and secret as flags
relay 3000 --server tunnel.example.com --secret your-secret
# Custom subdomain (persistent URL)
relay 3000 --subdomain myapp
# β https://myapp.tunnel.example.com (stays the same every time)
# Expose service at host:port
relay app:8080 --subdomain myapi
# Short flags
relay 3000 -n myapp
# Or use environment variables
export SERVER=tunnel.example.com
export SECRET=your-secret
export SUBDOMAIN=myapp
relay 3000# Set up env vars
export HOSTNAME=tunnel.example.com
export SECRET=$(openssl rand -base64 32)
# Run server
relay server
# Or with Docker Compose
docker compose -f docker-compose.server.yml up -d- A server with Docker installed
- Domain with wildcard DNS:
*.tunnel.example.comβ your server IP
Point both your domain and wildcard to your server:
A tunnel.example.com β 203.0.113.10
A *.tunnel.example.com β 203.0.113.10
HOSTNAME=tunnel.example.com
SECRET=$(openssl rand -base64 32)version: '3.8'
services:
relay:
image: talyuk/relay
command: server
ports:
- "8080:8080"
environment:
HOSTNAME: ${HOSTNAME}
SECRET: ${SECRET}
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: unless-stopped
volumes:
caddy_data:tunnel.example.com, *.tunnel.example.com {
reverse_proxy relay:8080
}
docker compose up -dβ
Done! Share the SECRET with your team.
- Get
SECRETfrom your team admin - Add to
.env:SERVER=tunnel.example.com SECRET=your-team-secret
- Add to your project's
docker-compose.dev.yml:services: app: build: . ports: - "3000:3000" relay: image: talyuk/relay command: app:3000 environment: SERVER: ${SERVER} SECRET: ${SECRET} depends_on: - app
- Run:
docker compose -f docker-compose.dev.yml up
| Variable | Required | Default | Description |
|---|---|---|---|
HOSTNAME |
Yes | - | Your domain (e.g., tunnel.example.com) |
SECRET |
Yes | - | Authentication secret |
PORT |
No | 8080 |
Server port |
| Variable / Flag | Required | Default | Description |
|---|---|---|---|
SERVER / --server |
Yes | - | Server hostname |
SECRET / --secret |
Yes | - | Authentication secret |
SUBDOMAIN / --subdomain, -n |
No | random | Custom subdomain (3-63 chars, alphanumeric + hyphens) |
Target can be passed as CLI argument or TARGET env var.
CLI Examples:
# With flags
relay 3000 --server tunnel.example.com --secret xxx --subdomain myapp
# With env vars
export SERVER=tunnel.example.com
export SECRET=xxx
export SUBDOMAIN=myapp
relay 3000
# Mix and match
export SERVER=tunnel.example.com
relay 3000 --secret xxx --subdomain myappKeep the same URL across restarts - perfect for webhooks and mobile apps.
# Random subdomain (changes each time)
relay 3000
# β https://quiet-snow-lamp.tunnel.example.com
# Custom subdomain (stays the same)
relay 3000 --subdomain myapp
# β https://myapp.tunnel.example.com (persistent!)
# Perfect for:
# - Webhook URLs that need to stay constant
# - Mobile app configs
# - Documentation/demos
# - Sharing with team over days/weeksPerfect for testing webhooks from services like Stripe, GitHub, Twilio, etc.
# Start your local webhook handler
npm start # Running on localhost:3000
# Expose it
relay 3000
# Use the URL in webhook configs
# https://quiet-snow-lamp.tunnel.example.com/webhookTest your mobile app against your local API without deploying.
# Expose your local API
relay 8080
# Use relay URL in mobile app config
# API_URL=https://bold-wave-tree.tunnel.example.comShare your local development environment with designers, PMs, or clients.
# Expose your local frontend
relay 5173
# Share with stakeholders
# https://calm-fire-drop.tunnel.example.comTest integration flows in your CI pipeline.
# GitHub Actions
- name: Expose service
run: |
docker run -d -e SERVER=${{ secrets.RELAY_SERVER }} \
-e SECRET=${{ secrets.RELAY_SECRET }} \
talyuk/relay app:3000Developer Machine Your Server (tunnel.example.com)
ββββββββββββββββ ββββββββββββββββββββββββββββββ
β App :3000 β β Caddy (HTTPS, port 80/443) β
β β β β β β
β Relay Client βββWebSocketββ Relay Server :8080 β
ββββββββββββββββ ββββββββββββββββββββββββββββββ
β
Public: https://xxx-yyy-zzz.tunnel.example.com
- Client connects to server via WebSocket with authentication
- Server generates random 3-word subdomain
- Server proxies HTTP requests through WebSocket to client
- Client forwards to local service and returns response
| Feature | Relay | ngrok | bore | sish |
|---|---|---|---|---|
| Self-hosted | β | β | β | β |
| Random subdomains | β | β | β (ports) | β |
| Custom subdomains | β | β (paid) | β | β |
| Memorable URLs | β | β | ||
| Single binary | β | β | β | β |
| Docker native | β | β | ||
| Setup complexity | Low | N/A | Low | Medium |
| Open source | β | β | β | β |
- π Keep
SECRETconfidential - treat it like a password - π Use HTTPS in production (Caddy handles this automatically)
- π₯ Only share SECRET with trusted team members
- π Rotate secrets periodically
- π‘οΈ Consider IP whitelisting at infrastructure level
- π Monitor active relays for abuse
β οΈ Don't expose sensitive services without additional auth
- Client not connected - check client logs
- Verify SECRET matches server configuration
- Ensure client is still running
- Check for typos/whitespace in SECRET
- Verify you're connecting to correct server
- Make sure SECRET wasn't changed on server
- Someone else is using that custom subdomain
- Choose a different name:
--subdomain myapp2 - Or omit
--subdomainflag to get random subdomain - Custom subdomains are first-come-first-served
- Use only lowercase letters, numbers, and hyphens
- Must be 3-63 characters
- Cannot start or end with hyphen
- Examples:
myapp,api-dev,staging-2024
- Ensure your app is running on the specified port
- In Docker, use service name (
app:3000) notlocalhost - For host machine services, use
host.docker.internal:3000
- Check network stability between client and server
- Client auto-reconnects every 5 seconds
- Review client and server logs for errors
- Verify firewall isn't blocking WebSocket connections
- Your local service is taking too long to respond
- Check if local service is healthy
- Default timeout is 30 seconds
# Clone repo
git clone https://github.com/talyuk/relay
cd relay
# Install dependencies
npm install
# Run server (dev mode)
export HOSTNAME=localhost
export SECRET=test123
npm run dev:server
# In another terminal: Run client (dev mode)
export SERVER=localhost:8080
export SECRET=test123
npm run dev 3000
# Build
npm run build
# Build Docker image
docker build -t talyuk/relay .You can configure multiple secrets for different teams:
# Server .env
ALLOWED_SECRETS=team-a-secret,team-b-secret,team-c-secret# Server .env
PORT=9000If you're using nginx or another reverse proxy instead of Caddy:
# nginx config
server {
listen 80;
server_name tunnel.example.com *.tunnel.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
See CONTRIBUTING.md for more details.
MIT License - see LICENSE for details
Created by talyuk
Inspired by ngrok, bore, and sish. Built to be simpler and easier to self-host.