Go Docker Nginx Open source

TunnelTerm

A containerized web terminal for a Linux host — ttyd behind an authenticated Nginx gateway, reaching the host through a loopback-restricted SSH key, with an optional Cloudflare Tunnel so the gateway can be published without opening a router port.

Role
Full-stack Developer
Ownership
Independent build
Scope
Gateway, auth service, containers, CLI launchers
Delivery
Open source, MIT

The Challenge

Reaching a home or self-hosted Linux box from a browser usually means either exposing SSH directly to the internet, or accepting a terminal-in-a-tab tool that hands out real shell access without much thought for the auth layer in front of it. Neither is something to leave running unattended.

TunnelTerm keeps the shell itself off the public internet entirely. ttyd and the SSH connection it wraps stay bound to loopback; the only thing reachable from outside is an Nginx gateway that checks a signed session on every request — page loads and the WebSocket upgrade alike — before it will proxy anything through.

My Role

I built the whole stack: the Go authentication service, the Nginx gateway configuration, the container images and their entrypoints, the generated CLI launchers for four platforms, and the setup script that turns a bare host into a running deployment.

What I Delivered

  • Authenticated gateway: Nginx's auth_request module gates every page and WebSocket upgrade behind a single internal call to the auth service — no route bypasses it.
  • Standalone Go auth service: bcrypt password check, constant-time username comparison, an HMAC-signed session cookie, and no session store to lose or leak.
  • Persistent host sessions: ttyd runs tmux new -A over SSH, so a session survives a closed tab and the next login drops back into the same shell.
  • Generated CLI launchers: a setup script produces POSIX and PowerShell scripts that install cloudflared on demand (including Termux's native package on Android) and tunnel SSH through Cloudflare, so CLI access needs no client-side pre-installation beyond an SSH client.
  • Container hardening: read-only root filesystems, cap_drop: ALL, no-new-privileges, and non-root users across every service, with none of them mounting the Docker socket, the host root filesystem, or host process namespaces.
  • Shell theming: matching Starship, tmux, and dircolors configs installable on the host, plus a shared xterm palette so the browser terminal and a locally attached ttyd service look identical.

Key Decisions

One auth check in front of everything. The gateway's only protected location block calls auth_request /_auth, which forwards the request cookie to the Go service's /verify endpoint. A 401 redirects to /login; anything else proxies straight through to ttyd, WebSocket upgrade headers included. There is exactly one place authentication can fail open, and it fails closed.

A restricted key, not a general-purpose one. The terminal container reaches the host as an ordinary SSH client with BatchMode, IdentitiesOnly, password authentication disabled, and a pinned known_hosts file. The setup script installs the container's key on the host with OpenSSH's restrict,from="127.0.0.1,::1" — it only authenticates from loopback, and forwarding, agent access, and X11 are all off, so the key is useless to anything except the container it was generated for.

Signed cookies, no session table. The session cookie carries the username and an expiry, base64-encoded and HMAC-SHA256 signed with a key that never leaves the auth container. Verifying a session is one signature check, with nothing to look up and nothing to clean up when it expires.

Rate limiting sized for a single-tenant service. Failed logins are tracked per client IP in a bounded in-memory map (capped at 4096 entries, a 15-minute window, five attempts before a lockout), which is enough for a service with one legitimate user and avoids the operational weight of an external store.

Hardening

  • ttyd binds to loopback only (-i lo) and the terminal, gateway, and auth containers all listen on 127.0.0.1; nothing is reachable except through the gateway.
  • Bcrypt credential hashing, an HMAC-signed session cookie, and a 250 ms delay plus per-IP throttling on failed logins.
  • A strict Content-Security-Policy, no-store caching, X-Frame-Options: DENY, and same-origin checks on every state-changing request.
  • Every container runs read-only, non-root, with all Linux capabilities dropped and no-new-privileges set.
  • The SSH key handed to the terminal container is installed with restrict,from="127.0.0.1,::1", so it only works from loopback and can't be used for forwarding or agent access.

Outcome

TunnelTerm is open source under MIT and deploys with a single setup script and docker compose up -d --build. It's the terminal I actually use to reach my own boxes — from a browser through the authenticated gateway, or from a CLI launcher that tunnels SSH through Cloudflare without ever opening a port.