How I shipped ssh agnelnieves.sh, a terminal portfolio in Rust using ratatui, russh, and Fly.io. One binary, two modes: local CLI and SSH server.
Building a Terminal Portfolio You Can SSH Into
TL;DR
You can now browse this site in your terminal:
ssh agnelnieves.shcli/ package inside the same Next.js project that powers this site, so the website and the terminal share a single deploy story. If you want the full build (architecture, code, Fly.io setup), I wrote it up as a step-by-step guide.The setup
ssh agnelnieves.sh. That's it. No -l user@, no keys to add, no first-time signup. The server accepts whatever username your local SSH client offers and drops you straight into a terminal UI.h/a/b/c to jump between Home, About, Blog, and Connect. j/k (or arrows) to navigate lists. Enter to open. q to quit, which closes the SSH session.Why I built it
ssh terminal.shop (that's the whole storefront, no website) and the first time I tried it I wanted to know if I could build something like it.I do side projects like this when I need to step away from whatever I'm shipping day-to-day. They're not pitches. They don't need a thesis. They're more like the way some people fix a bike on a Saturday: sit down, follow whatever's pulling on your curiosity, and at the end you have a thing that didn't exist before. That's the whole reason.
ssh agnelnieves.sh actually work. Turns out: less than I expected. The hardest parts were the ones I didn't see coming, which is what makes any of this fun.The architecture
One binary. Two modes. One render path.
┌─────────────────────────────┐
│ agnel (single .exe) │
└──────────────┬──────────────┘
│
┌──────────────┴──────────────┐
│ │
agnel (no flag) agnel --serve
Local TUI SSH server (Fly.io)
crossterm stdin russh + custom backend
│ │
└──────────┬──────────────────┘
│
src/render.rs ← shared
│
Per-session App state
│
Live fetches from agnelnieves.com APIs
The pieces:
- TUI: ratatui draws everything: header, ticker, ASCII banner, projects list, blog reader. The render function lives in
src/render.rsand is identical whether the binary is running on your laptop or whether you're seeing it over SSH. - State machine:
Appinsrc/app.rsowns screen, scroll position, list selection, ticker animation, and async-fetch results funneled through anmpsc::Receiver. - Data: zero content is bundled. The CLI fetches live from
/feed.json,/api/blog/[slug]/raw,/api/projects, and/api/site.jsonon this site, cached for an hour at~/.agnel/cache/. So a new blog post here shows up in your terminal immediately without redeploying anything. - SSH server: russh 0.60 in
src/serve.rs. One tokio task per session.
The decision that made it easy: don't fork a PTY
exec-ing the TUI binary inside it. That's how mosh, tmux, and Go's wish library (the one terminal.shop uses) handle things. It works, but it's heavier:- Adds a libc dependency (
forkpty) - Forks a process per connection (more memory, slower spawn)
- Hard to share state between the SSH layer and the app
ratatui_app example showed a cleaner path: implement a custom std::io::Write backend that funnels bytes through russh's Handle::data(channel, bytes) API. Ratatui doesn't know it's writing to an SSH channel instead of stdout. No fork, no PTY, just bytes:struct TerminalSink {
sender: UnboundedSender<Vec<u8>>,
sink: Vec<u8>,
}
impl std::io::Write for TerminalSink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.sink.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if !self.sink.is_empty() {
let _ = self.sender.send(std::mem::take(&mut self.sink));
}
Ok(())
}
}App and its own Terminal<CrosstermBackend<TerminalSink>>. The lock graph stays tiny: an Arc<Mutex<SessionState>> per session, plus a HashMap of all sessions for cleanup.data() callback, and a small parser turns them into the same crossterm::event::KeyEvent values the local TUI uses. That meant zero changes to App::handle_key:fn parse_keys(buf: &[u8]) -> Vec<KeyEvent> {
// 0x03 → Ctrl+C, 0x1b[A → Up, 0x1b alone → Esc,
// printable ASCII → Char(b), etc.
}window_change_request calls terminal.resize(rect), and ratatui redraws on the next 50 ms tick.The result: a single binary you can drop on a VPS, in a container, or even into a Lambda-style worker, and it just serves the TUI.
What I learned about hosting
A few things I didn't expect.
1. Fly trial orgs block dedicated IPv4, and SSH needs IPv4. For HTTP, Fly's edge gives you a shared anycast IPv4 for free. For raw TCP on port 22, you need a dedicated v4 (~$2/mo), and trial orgs can't allocate one. Pure-IPv6 isn't a real option, either: most US residential ISPs still don't have native IPv6 SSH paths. Adding a card was the unlock.
REMOTE HOST IDENTIFICATION HAS CHANGED warnings after the next deploy. The fix is a 1 GB Fly volume mounted at /data, with --host-key /data/ssh_host_key. First boot generates and writes the key; every boot after just reads it.auto_stop_machines = "off" and min_machines_running = 1 if you want a long-running service.fly proxy is fine for HTTP, weird for raw SSH. I burned half an hour on Connection reset by peer through fly proxy 12222:2222 before realizing I should just bash /dev/tcp/127.0.0.1/2222 from inside the machine via flyctl ssh console. The SSH server was healthy the whole time; the proxy path was the problem.ANSI Shadow beats slant
slant font. It looks fine in a docs file. At terminal width with full-block rendering, it falls apart into disconnected diagonals.Visitor #N
visitor #N. First SSH session ever was #1, second was #2, and so on. The count lives in a tiny text file on the Fly volume next to the host key, increments once per channel_open_session, and persists across deploys (host-key lesson applied).AtomicU64 loaded from the file at startup, a spawn_blocking write after each fetch_add. The file is just a base-10 number with no header, so cat /data/visitor_count is the dashboard.There's no leaderboard. No real meaning to the number. But watching it tick up after shipping is the moment the whole side project starts feeling worth it.
The stack
| Layer | Tech |
|---|---|
| TUI | ratatui 0.29, crossterm 0.28 |
| SSH | russh 0.60 |
| HTTP client | ureq 2 (sync, plenty for this) |
| Async runtime | tokio 1 |
| CLI args | clap 4 |
| Host | Fly.io (iad, shared-cpu-1x, 256 MB, 1 GB volume) |
| DNS | Vercel Domains (A + AAAA on the apex) |
| Binary size | ~3 MB release (macOS arm64), 4.3 MB (Linux x86_64) |
| Image size | 28 MB (Debian bookworm-slim runtime) |
| Cost | $2/mo for the dedicated IPv4 + free tier for the rest |
What's next
- Pretty errors and loading states. Today they say
Loading…which is functional but boring. - Project deep-links. I render project metadata but not the original write-ups. Pulling MDX through the API would let me ship long-form case studies in-terminal.
- Cross-platform release binaries. The current install path is
cargo install --path cli/. Homebrew tap + release artifacts for macOS and Linux is a Saturday morning.
Try it
ssh agnelnieves.sh

