DEV Community

Cover image for I Replaced Termius with a Browser Tab (And It Has ESC, Ctrl, and F-keys)
Shifu
Shifu

Posted on

I Replaced Termius with a Browser Tab (And It Has ESC, Ctrl, and F-keys)

Every time I needed to check on a server from my phone, I went through the same ritual.

Open Termius. Find the saved connection. Enter credentials. Get into the shell. Then the fun begins: I need to cancel a running command — where's Ctrl+C? I need to exit vim — where's ESC? I need to tab-complete a long path — where's TAB?

Termius handles all of this, but I was paying $14/month for what amounts to a keyboard with special keys. And it only works over SSH — if my server doesn't expose an SSH daemon to the internet, I'm stuck.

So I built NomadTTY — a self-hosted web terminal that runs in any browser, with a touch-native keyboard toolbar baked in. No app. No subscription. No SSH daemon on the public internet.

What It Is

Three components, nothing extra:

  1. ttyd — exposes a bash/tmux session over WebSocket
  2. nginx — proxies ttyd and injects a keyboard toolbar before the page loads
  3. tmux — keeps your session alive when you close the browser tab

The toolbar lives at the top of the browser and gives you the keys your phone keyboard is missing:

ESC · TAB · CTRL · SHFT · ALT · ↑↓←→ · HOME · END · PGUP/PGDN · F1–F12

Modifiers are sticky: tap CTRL, then type a letter on your phone keyboard, and it sends Ctrl+C (or whatever letter). No holding. No gymnastics. This is how Termius does it — I just wanted the same thing in a browser tab.

Try It in 30 Seconds

docker run -d -p 80:80 --name nomadtty ghcr.io/shifulegend/nomadtty:latest
Enter fullscreen mode Exit fullscreen mode

Open http://localhost in your mobile browser. The toolbar appears at the top. The terminal fills the rest of the screen.

How the Toolbar Gets Injected (The Neat Part)

nginx has a sub_filter directive that can replace strings in proxied HTTP responses. NomadTTY uses this to inject three things into ttyd's HTML before the page reaches the browser:

  1. A mobile viewport meta tag
  2. A 270-byte inline script that hooks window.WebSocket to capture the /ws connection as window._S
  3. <script src="/kb.js" defer></script> — the 9 KB keyboard toolbar

The toolbar sends keystrokes by calling window._S.send('0' + bytes) — the same protocol ttyd's own frontend uses. No fork. No modification to ttyd's source. No rebuild.

Phone browser → nginx :80
  ├── GET /kb.js  →  keyboard toolbar (9 KB, zero deps)
  ├── GET /ws     →  ttyd WebSocket passthrough
  └── GET /       →  ttyd HTML + injected toolbar + viewport meta
                              ↓
                        tmux new-session -A -s main
                              ↓
                        persistent bash (survives browser close)
Enter fullscreen mode Exit fullscreen mode

Installing on a Real Server

# One-command install (Debian/Ubuntu)
curl -fsSL https://raw.githubusercontent.com/shifulegend/nomadtty/main/install.sh | sudo bash

# With a custom domain
NOMADTTY_HOST=terminal.example.com sudo -E bash -c \
  'curl -fsSL https://raw.githubusercontent.com/shifulegend/nomadtty/main/install.sh | bash'
Enter fullscreen mode Exit fullscreen mode

Works great over Tailscale — expose NomadTTY only on your private network and reach it from anywhere your phone can hit Tailscale. No public SSH port required.

Keyboard at a Glance

Key Sends
CTRL Sticky — tap, then type a letter (e.g. Ctrl+C)
SHFT Sticky shift modifier
ALT Sticky — ESC prefix for readline shortcuts (Alt+B, Alt+F)
ESC \x1b
TAB / ⇑TAB \t / Shift+Tab
↑↓←→ Arrow keys (modifier combos supported)
HOME / END \x1b[H / \x1b[F
PGUP / PGDN \x1b[5~ / \x1b[6~
Fn Toggle F1–F12 row
A− / A+ Zoom terminal font in/out

What It Won't Do

Being honest about the trade-offs:

  • Not an SSH client — NomadTTY is a web terminal, not a tunnel or port-forward tool
  • One server at a time — no multi-host UI; one NomadTTY instance per server
  • No key vault — auth is on you (Tailscale, nginx basic auth, or VPN)
  • Browser-only — needs a network connection; no offline mode

If you need Termius-style multi-server management or SSH tunneling: keep Termius. If you have one server you care about and want to reach it cleanly from your phone or tablet without installing anything — this does it.

The Stack

  • ttyd — terminal-over-WebSocket, unmodified
  • nginx — toolbar injection via sub_filter; static serving of kb.js
  • tmux — session persistence
  • src/kb.js — 9 KB IIFE, zero dependencies, no build step

No Node.js. No npm. No bundler. Docker images for linux/amd64 and linux/arm64. MIT licensed.

Get It

github.com/shifulegend/nomadtty


What do you currently use for terminal access from your phone? Termius? Blink Shell? Raw SSH apps? Something else entirely? I'm curious what the community's solutions look like — and what pain points I may have missed. Drop it in the comments.

Top comments (3)

Collapse
 
wrencalloway profile image
Wren Calloway

The sub_filter injection is the part I'd steal. Hooking window.WebSocket to grab the connection and then just speaking ttyd's own '0'+bytes framing means you never fork or rebuild the thing you're wrapping — which is exactly why it stays maintainable. Most people would've forked ttyd and inherited its whole release treadmill.

The seam I'd watch: that trick is coupled to ttyd's wire protocol, which is an implementation detail, not an API. The day ttyd changes how it frames input, the toolbar keeps "working" — buttons still light up, nothing errors — but keystrokes quietly stop landing. Pin the ttyd image version and treat a ttyd bump as something that needs a re-test, or that's a fun 2am debugging session waiting to happen.

+1 on making the Tailscale bit load-bearing rather than optional, too. A persistent tmux behind a web terminal is a root shell that survives you closing the tab — on a public port that's less "auth is on you" and more "your box is on the internet." Clean project though; the injection approach is genuinely sharp.

Collapse
 
shifu_legend profile image
Shifu

Yeah, that's the sharp edge and you're right to flag it. Right now the toolbar assumes ttyd's plaintext frame format (0x30 + payload for stdin), and I pin the ttyd image tag in the compose file specifically because of that - but "pinned" still means someone (future me) has to remember to re-test on every bump instead of getting a build failure. No version check against the actual wire protocol yet, which is the correct fix; I just haven't written it.

Good catch on the failure mode too - silent, not loud. That's worse than it sounds until you've hit it once.

On Tailscale: it's the reason the whole thing feels safe to me day to day, but you're right that it's currently a README recommendation, not something enforced. Next thing on the list is probably binding ttyd to the tailscale0 interface by default so "expose to the internet" takes an actual extra step instead of being the default path.

Collapse
 
frank_signorini profile image
Frank

The sticky Ctrl and F-key emulation in a