SSH directly into your Docker containers without exposing ports or managing complex networking.
Tunnyd is a lightweight SSH proxy that lets you access Docker containers by hostname through your existing SSH infrastructure. Label your containers, configure your SSH client once, and connect to any container with a simple ssh command.
Running SSH daemons in Docker containers is cumbersome:
- Each container needs its own exposed SSH port (2222, 2223, 2224...)
- You have to manage SSH keys and users inside every container
- Port management becomes a nightmare with many containers
- Bastion host configurations are complex and error-prone
- Container images bloat with SSH server installations
There has to be a better way.
Tunnyd acts as a smart SSH proxy on your Docker host:
- No SSH in containers - Containers don't need SSH servers installed
- No port exposure - Tunnyd uses
docker execinternally, no container ports needed - Hostname-based routing - Access containers by name:
ssh user@myapp.docker - Label-based access control - Simple Docker labels control who can access what
- Works with existing SSH - Uses your SSH client, keys, and ProxyJump setup
One Tunnyd instance serves all your containers.
- Zero container modifications - Use any image, no SSH installation needed
- Dynamic discovery - Containers are found automatically via Docker labels
- Access control - Restrict users per container with simple labels
- Secure by design - Leverages SSH ProxyJump for authentication
- Minimal overhead - Written in Rust for performance and safety
- Simple setup - Configure once, works for all containers
- Docker installed and running
- Rust toolchain (for building from source)
- SSH access to your Docker host (for ProxyJump)
- Docker socket access at
/var/run/docker.sock
- Clone and build:
git clone https://github.com/yourusername/tunnyd.git
cd tunnyd
cargo build --release- Copy binary to your Docker host:
# On your Docker host
sudo cp target/release/main /usr/local/bin/tunnyd
sudo chmod +x /usr/local/bin/tunnyd- Run Tunnyd (on your Docker host):
tunnyd
# Listens on port 2222Security: Ensure port 2222 is NOT exposed to the internet. Configure your firewall to block external access:
# Example: UFW firewall - allow only localhost
sudo ufw deny 2222
sudo ufw allow from 127.0.0.1 to any port 2222
# Or allow only from internal network
sudo ufw allow from 192.168.0.0/16 to any port 2222Step 1: Label your Docker containers
Add these labels to containers you want to access via SSH:
version: "3.8"
services:
myapp:
image: ubuntu:latest
labels:
- tunnyD.enable=true
- tunnyD.hostname=myapp.docker
- tunnyD.allowed.users=developer,admin # Optional: restrict usersStep 2: Configure your SSH client (~/.ssh/config):
Host *.docker
HostName 192.168.1.100 # Your Docker host IP
Port 2222 # Tunnyd listens on this port
User %r # Pass through your username
ProxyJump user@192.168.1.100 # SSH to SAME host port 22 first (authenticate)
PreferredAuthentications none
RequestTTY yes
RemoteCommand tunnyd --target %n --user %r
Important: ProxyJump and HostName are the same server. You SSH to the host on port 22 (authenticate), then connect to port 2222 (Tunnyd) on the same machine.
Step 3: Connect!
ssh developer@myapp.docker
# You're now in a bash shell inside the container!Here's what happens when you run ssh developer@myapp.docker:
1. Your SSH Client
├─> Connects to Docker host on port 22 (ProxyJump)
│ └─> Standard SSH daemon authenticates you with SSH key
│ └─> ✓ Authentication happens HERE
│
2. From Docker Host → Back to Docker Host on port 2222
├─> ProxyJump forwards you to Tunnyd (same server, different port)
├─> Sends RemoteCommand: "tunnyd --target myapp.docker --user developer"
│
3. Tunnyd (port 2222)
├─> NO authentication (you already proved you can SSH to the host)
├─> Queries Docker API for containers
├─> Finds container with labels:
│ • tunnyD.enable=true
│ • tunnyD.hostname=myapp.docker
│ • tunnyD.allowed.users contains "developer"
│
4. Tunnyd → Docker
├─> Runs: docker exec -it -u developer <container> bash
│
5. Tunnyd bridges I/O
├─> Your keystrokes → container stdin
└─> Container stdout/stderr → your terminal
✓ You now have an interactive shell in the container
ProxyJump connects to the SAME server twice on different ports:
┌─────────────────────────────────────┐
│ Docker Host (192.168.1.100) │
│ │
│ Port 22 Port 2222 │
│ ┌──────────┐ ┌──────────┐ │
│ │ SSH │ │ Tunnyd │ │
│ │ Daemon │───────>│ (proxy) │ │
│ │ (auth) │ jump │ (no auth)│ │
│ └──────────┘ └─────┬────┘ │
│ ▲ │ │
│ │ │ │
│ │ ▼ │
│ You connect docker exec │
│ here first to container │
│ │
└─────────────────────────────────────┘
Why this architecture?
- Port 22: Regular SSH daemon handles authentication (SSH keys, passwords)
- Port 2222: Tunnyd just routes to containers (no auth needed - you're already in!)
- Security: If you can SSH to the host, you can access Tunnyd. Simple.
Security Notes:
- Tunnyd itself does not authenticate users. Security is enforced by the host's SSH daemon on port 22.
- IMPORTANT: Port 2222 should NOT be exposed to external networks. Use firewall rules to restrict access to localhost only, or at minimum to your internal network. Since Tunnyd doesn't authenticate, exposing port 2222 publicly would allow anyone to access your containers.
Three labels control container access:
| Label | Required | Description | Example |
|---|---|---|---|
tunnyD.enable |
Yes | Must be "true" to enable access |
tunnyD.enable=true |
tunnyD.hostname |
Yes | Hostname for SSH access | tunnyD.hostname=api.docker |
tunnyD.allowed.users |
No | Comma-separated users (empty = all allowed) | tunnyD.allowed.users=git,deploy |
version: "3.8"
services:
# Public access container
web:
image: nginx:latest
labels:
- tunnyD.enable=true
- tunnyD.hostname=web.docker
# No allowed.users = anyone can access
# Restricted access container
database:
image: postgres:15
labels:
- tunnyD.enable=true
- tunnyD.hostname=db.docker
- tunnyD.allowed.users=dba,admin
# Developer workspace
devbox:
image: node:20
labels:
- tunnyD.enable=true
- tunnyD.hostname=dev.docker
- tunnyD.allowed.users=alice,bob,carolEdit ~/.ssh/config:
Host *.docker
# Docker host running Tunnyd
HostName 192.168.1.100
Port 2222
# Security: First SSH to the SAME host on port 22 (authenticate there)
ProxyJump myuser@192.168.1.100
# Pass through username
User %r
# Tunnyd doesn't authenticate (handled by ProxyJump on port 22)
PreferredAuthentications none
# Enable interactive terminal
RequestTTY yes
# Execute Tunnyd with target and user
RemoteCommand tunnyd --target %n --user %r
Critical Detail: ProxyJump connects to the same IP address as HostName:
- First: SSH to
192.168.1.100:22(standard SSH, authenticate with your key) - Then: From there, connect to
192.168.1.100:2222(Tunnyd, no auth needed)
Replace 192.168.1.100 with your actual Docker host IP in BOTH places.
Connection multiplexing (faster reconnections):
Host *.docker
ControlMaster auto
ControlPath ~/.ssh/sockets/%r@%h:%p
ControlPersist 10m
Timeouts and keepalives:
Host *.docker
ConnectTimeout 10
ServerAliveInterval 60
ServerAliveCountMax 3
# Connect as "developer" user
ssh developer@myapp.docker
# Connect as "root" user
ssh root@myapp.docker
# Run a single command
ssh developer@myapp.docker ls -la /var/log
# Run interactive command
ssh developer@myapp.docker -t htop# docker-compose.yml
services:
app-prod:
labels:
- tunnyD.enable=true
- tunnyD.hostname=app-prod.docker
- tunnyD.allowed.users=ops
app-staging:
labels:
- tunnyD.enable=true
- tunnyD.hostname=app-staging.docker
- tunnyD.allowed.users=developer,qa# Operations access to production
ssh ops@app-prod.docker
# Developer access to staging
ssh developer@app-staging.dockerservices:
auth-service:
labels:
- tunnyD.enable=true
- tunnyD.hostname=auth.services.docker
payment-service:
labels:
- tunnyD.enable=true
- tunnyD.hostname=payment.services.docker
notification-service:
labels:
- tunnyD.enable=true
- tunnyD.hostname=notify.services.dockerssh admin@auth.services.docker
ssh admin@payment.services.docker
ssh admin@notify.services.docker# Check container is running
docker ps | grep myapp
# Verify labels are set correctly
docker inspect myapp | grep tunnyD
# Expected output:
# "tunnyD.enable": "true",
# "tunnyD.hostname": "myapp.docker",- Verify Tunnyd is running:
ps aux | grep tunnyd - Check Tunnyd is listening:
netstat -tlnp | grep 2222 - Test ProxyJump host:
ssh myuser@dockerhost.example.com - Check firewall allows port 2222
- Verify user in
tunnyD.allowed.userslabel (if set) - Check username matches exactly (case-sensitive)
- Ensure user exists in container:
docker exec myapp id developer
If you see "bash: not found", the container doesn't have bash installed.
Option 1: Change shell in Tunnyd source (src/server.rs:161):
cmd: Some(vec!["sh"]), // Use sh instead of bashOption 2: Install bash in your container image.
Perfect for:
- Development environments with many microservices
- DevOps teams managing containerized infrastructure
- Debugging production containers without installing SSH
- Multi-tenant container platforms
- CI/CD environments needing temporary container access
Not recommended for:
- Production SSH access (use proper bastion hosts)
- Public-facing services (port 2222 must be firewalled - never expose to internet)
- Containers that need persistent SSH sessions
- Environments where Tunnyd port 2222 cannot be restricted to internal networks only
For detailed technical information:
- Architecture and Design - System internals, data flow, and component details
- Configuration Reference - Complete reference for all configuration options
Contributions are welcome! We appreciate:
- Bug reports and feature requests (open an issue)
- Documentation improvements
- Code contributions (open a pull request)
- Use case examples and tutorials
Please ensure your code follows Rust best practices and includes appropriate tests.
Tunnyd is licensed under the MIT License. See the LICENSE file for details.
Made with ❤️ in Rust