← Back to blog

Building a Terminal Portfolio You Can SSH Into

By Agnel Nieves8 min read
Rust
Ratatui
SSH
Fly.io
Design Engineering
Side Projects
View as Markdown

TL;DR

You can now browse this site in your terminal:

ssh agnelnieves.sh

No install. One Rust binary that runs as either a local CLI or an in-binary SSH server. No system sshd, no user accounts, no auth. The TUI is built with ratatui; the SSH layer is russh; render code is shared between local and remote modes. It's hosted on Fly.io behind a dedicated IPv4, and the whole thing costs about $2/mo. The code lives as a cli/ 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.

Press 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

Honestly? I saw terminal.shop and thought it was awesome. They sell coffee at 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.

So this was that. A few evenings poking at ratatui and russh to see what it would take to make 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.rs and is identical whether the binary is running on your laptop or whether you're seeing it over SSH.
  • State machine: App in src/app.rs owns screen, scroll position, list selection, ticker animation, and async-fetch results funneled through an mpsc::Receiver.
  • Data: zero content is bundled. The CLI fetches live from /feed.json, /api/blog/[slug]/raw, /api/projects, and /api/site.json on 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

Most "expose a TUI over SSH" guides suggest spawning a pseudo-terminal and 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

Russh's 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(())
    }
}

A background tokio task drains the channel and pushes bytes back to the client through russh. Each session gets its own 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.

Inputs go the other direction. Russh hands raw bytes to my 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 resize hooks into the same place. 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.

2. The SSH host key has to persist. If the container generates a fresh Ed25519 key on every boot, every returning visitor gets 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.

3. Trial machines auto-stop after 5 minutes. That's how the Fly free tier nudges you toward billing. Once you're paying, set auto_stop_machines = "off" and min_machines_running = 1 if you want a long-running service.

4. 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

The first banner I shipped used figlet's slant font. It looks fine in a docs file. At terminal width with full-block rendering, it falls apart into disconnected diagonals.

Swapped to ANSI Shadow, the same boxy block font terminal.shop and charm.sh use. Six rows tall, about 93 columns wide, reads instantly. Probably the kind of thing I'd have gotten right the first time if I'd looked at more references before opening figlet.

Visitor #N

One fun thing I added. Look at the bottom-left of the footer when you connect: it says 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).

Implementation is about thirty lines. An 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

LayerTech
TUIratatui 0.29, crossterm 0.28
SSHrussh 0.60
HTTP clientureq 2 (sync, plenty for this)
Async runtimetokio 1
CLI argsclap 4
HostFly.io (iad, shared-cpu-1x, 256 MB, 1 GB volume)
DNSVercel Domains (A + AAAA on the apex)
Binary size~3 MB release (macOS arm64), 4.3 MB (Linux x86_64)
Image size28 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

If you want to build your own version, the guide has the full setup (Rust + ratatui + russh + Fly.io), with a copy-paste prompt at the top so you can hand it to your AI agent of choice. If you ship one, let me know.


Built by Agnel Nieves, a design engineer with 15+ years across product, design systems, and crypto. More writing on the blog.

View as Markdown