---
title: "Building a Terminal Portfolio You Can SSH Into"
date: 2026-05-12
excerpt: "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."
author: "Agnel Nieves"
tags: ["Rust", "Ratatui", "SSH", "Fly.io", "Design Engineering", "Side Projects"]
status: "published"
coverImage: ""
lastModified: 2026-05-12
---

## TL;DR

You can now browse this site in your terminal:

```bash
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](https://ratatui.rs); the SSH layer is [russh](https://docs.rs/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](/guides/ssh-terminal-portfolio.md).

## 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](https://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](https://ratatui.rs) and [russh](https://docs.rs/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](https://ratatui.rs) 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](https://docs.rs/russh) 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`](https://charm.sh/blog/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:

```rust
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`:

```rust
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](https://patorjk.com/software/taag/#p=display&f=ANSI%20Shadow&t=AGNEL%20NIEVES), the same boxy block font terminal.shop and [charm.sh](https://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

| 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

```bash
ssh agnelnieves.sh
```

If you want to build your own version, the [guide](/guides/ssh-terminal-portfolio.md) 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](/connect).

---

*Built by [Agnel Nieves](/about), a design engineer with 15+ years across product, design systems, and crypto. More writing on [the blog](/blog).*
