This moots the entire need for argo tunnels using ssh which is already basically perfect and traefik and labels n so on.
Go to file
Leopere 64347ce8a5
ci/woodpecker/push/woodpecker Pipeline was successful Details
Switch from Swarm labels to Traefik file provider for routing
docker service update --label-add was restarting the tunnel-server
container on every label change, breaking all active SSH tunnels.

Now the server writes YAML config files to /root/traefik/dynamic/ on
the Traefik host via SSH. Traefik's file provider watches the directory
and picks up changes without any container restarts. Clients can
reconnect reliably after server restarts with no restart loops.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 15:06:21 -05:00
cmd Switch from Swarm labels to Traefik file provider for routing 2026-02-09 15:06:21 -05:00
internal Switch from Swarm labels to Traefik file provider for routing 2026-02-09 15:06:21 -05:00
.gitignore Add test stack and ignore compiled binaries 2026-02-09 14:41:50 -05:00
.woodpecker.yml Replace Docker secrets with host bind mounts for SSH keys 2026-02-08 18:45:56 -05:00
Dockerfile Initial commit: reverse SSH tunnel server for Traefik 2026-02-08 18:16:41 -05:00
README.md Initial commit: reverse SSH tunnel server for Traefik 2026-02-08 18:16:41 -05:00
docker-compose.production.yml Add Woodpecker CI, production stack, and compose files 2026-02-08 18:18:54 -05:00
docker-compose.test.yml Add restart: always to local compose services 2026-02-09 15:00:11 -05:00
docker-compose.yml Add Woodpecker CI, production stack, and compose files 2026-02-08 18:18:54 -05:00
go.mod Initial commit: reverse SSH tunnel server for Traefik 2026-02-08 18:16:41 -05:00
go.sum Initial commit: reverse SSH tunnel server for Traefik 2026-02-08 18:16:41 -05:00
stack.production.yml Add optional HTTP Basic Auth support for tunnel clients 2026-02-09 14:40:58 -05:00
test-index.html Add test stack and ignore compiled binaries 2026-02-09 14:41:50 -05:00

README.md

Reverse SSH Tunnel Server for Traefik

A lightweight Go system that lets remote Docker hosts expose HTTP services through a central Traefik reverse proxy via SSH tunnels. The server SSHs into the Traefik ingress host to dynamically manage route configuration.

Architecture

Remote Host                        Tunnel Server              Traefik Host (ingress.nixc.us)
+--------------+                   +---------------------+    +---------------------+
| tunnel-client| ---SSH tunnel---> | tunnel-server       |    | Traefik             |
| TUNNEL_KEY   |                   | allocates port,     |    | watches dynamic/    |
| TUNNEL_DOMAIN|                   | SSHs into Traefik   |--->| routes HTTPS to     |
| TUNNEL_PORT  |                   | host to write config |    | tunnel-server ports |
+--------------+                   +---------------------+    +---------------------+

Flow

  1. Client connects to tunnel-server via SSH using a private key
  2. Client sends domain metadata ({"domain":"myapp.example.com"}) over a custom channel
  3. Server allocates a port from the pool and sets up a reverse port forward
  4. Server SSHs into ingress.nixc.us and writes a Traefik dynamic config file
  5. Traefik detects the new config and routes HTTPS traffic to tunnel-server:<port>
  6. Traffic flows: Internet -> Traefik -> tunnel-server:port -> SSH tunnel -> client -> local service
  7. When the client disconnects, the config file is removed and the port is freed

Quick Start

1. Generate SSH Keys

mkdir -p keys

# Server host key (for the SSH server that clients connect to)
ssh-keygen -t ed25519 -f keys/host_key -N ""

# Client key (for tunnel clients)
ssh-keygen -t ed25519 -f keys/id_ed25519 -N ""

# Authorize the client
cat keys/id_ed25519.pub > keys/authorized_keys

# Deploy key for SSHing into the Traefik host
# (use an existing key that has root access to ingress.nixc.us,
#  or generate one and add its .pub to the Traefik host's authorized_keys)
cp ~/.ssh/ingress_deploy_key keys/traefik_deploy_key

2. Enable Traefik File Provider

On ingress.nixc.us, ensure Traefik has the file provider enabled. If Traefik is run via Docker stack with CLI args, add:

--providers.file.directory=/root/traefik/dynamic
--providers.file.watch=true

Or via SSH (non-interactive):

ssh root@ingress.nixc.us "mkdir -p /root/traefik/dynamic"

Then update your Traefik stack/service to mount the directory and add the CLI args.

3. Start the Tunnel Server

docker compose up -d

4. Run a Client

On any remote host where your service is running:

Using a mounted key file:

docker run -d \
  --name tunnel-client \
  -e TUNNEL_SERVER=ingress.nixc.us:2222 \
  -e TUNNEL_DOMAIN=myapp.example.com \
  -e TUNNEL_PORT=8080 \
  -e TUNNEL_KEY=/keys/id_ed25519 \
  -v /path/to/keys:/keys:ro \
  --network host \
  tunnel-client

Using raw PEM key content in an envvar:

docker run -d \
  --name tunnel-client \
  -e TUNNEL_SERVER=ingress.nixc.us:2222 \
  -e TUNNEL_DOMAIN=myapp.example.com \
  -e TUNNEL_PORT=8080 \
  -e "TUNNEL_KEY=$(cat /path/to/id_ed25519)" \
  --network host \
  tunnel-client

Environment Variables

Server

Variable Description Default
SSH_PORT SSH listen port for tunnel clients 2222
PORT_RANGE_START First allocatable tunnel port 10000
PORT_RANGE_END Last allocatable tunnel port 10100
SSH_HOST_KEY Path to SSH host private key /keys/host_key
AUTHORIZED_KEYS Path to authorized_keys file /keys/authorized_keys
TRAEFIK_SSH_HOST Traefik host to SSH into (required) -
TRAEFIK_SSH_USER SSH user on the Traefik host root
TRAEFIK_SSH_KEY SSH key for Traefik host (path or PEM) (required) -
TRAEFIK_CONFIG_DIR Remote path for dynamic configs /root/traefik/dynamic
TRAEFIK_ENTRYPOINT Traefik entrypoint name websecure
TRAEFIK_CERT_RESOLVER Traefik TLS cert resolver letsencryptresolver

Client

Variable Description Default
TUNNEL_SERVER Server host:port (required) -
TUNNEL_DOMAIN Public domain to expose (required) -
TUNNEL_PORT Local port of your service 8080
TUNNEL_KEY SSH private key — file path or raw PEM (required) /keys/id_ed25519

Generated Traefik Config

When a client connects requesting myapp.example.com, the server writes this to the Traefik host:

# /root/traefik/dynamic/tunnel-myapp-example-com.yml
http:
  routers:
    tunnel-myapp-example-com-router:
      rule: "Host(`myapp.example.com`)"
      entryPoints:
        - websecure
      tls:
        certResolver: letsencryptresolver
      service: tunnel-myapp-example-com-service
  services:
    tunnel-myapp-example-com-service:
      loadBalancer:
        servers:
          - url: "http://tunnel-server:10042"

Building

# Build both binaries locally
go build -o tunnel-server ./cmd/server/
go build -o tunnel-client ./cmd/client/

# Build Docker images
docker compose build                                # server image
docker build --target client -t tunnel-client .     # client image

Security Notes

  • Only clients whose public keys are in authorized_keys can connect
  • The server uses a stable host key for client verification
  • SSH tunnels encrypt all traffic between client and server
  • The server authenticates to the Traefik host with a separate deploy key
  • Traefik handles TLS termination with automatic Let's Encrypt certificates