<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Agnel Nieves - SSH</title>
    <link>https://agnelnieves.com/blog/tag/ssh</link>
    <description>Blog posts on SSH by Agnel Nieves.</description>
    <language>en-US</language>
    <lastBuildDate>Fri, 15 May 2026 01:12:12 GMT</lastBuildDate>
    <atom:link href="https://agnelnieves.com/blog/tag/ssh/feed.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title><![CDATA[Building a Terminal Portfolio You Can SSH Into]]></title>
      <link>https://agnelnieves.com/blog/building-a-terminal-portfolio-you-can-ssh-into</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/building-a-terminal-portfolio-you-can-ssh-into</guid>
      <description><![CDATA[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.]]></description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>You can now browse this site in your terminal:</p>
<pre><code class="language-bash">ssh agnelnieves.sh
</code></pre>
<p>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 <a href="https://ratatui.rs">ratatui</a>; the SSH layer is <a href="https://docs.rs/russh">russh</a>; render code is shared between local and remote modes. It&#39;s hosted on Fly.io behind a dedicated IPv4, and the whole thing costs about $2/mo. The code lives as a <code>cli/</code> 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 <a href="/guides/ssh-terminal-portfolio.md">step-by-step guide</a>.</p>
<h2>The setup</h2>
<p><code>ssh agnelnieves.sh</code>. That&#39;s it. No <code>-l user@</code>, 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.</p>
<p>Press <code>h</code>/<code>a</code>/<code>b</code>/<code>c</code> to jump between Home, About, Blog, and Connect. <code>j</code>/<code>k</code> (or arrows) to navigate lists. <code>Enter</code> to open. <code>q</code> to quit, which closes the SSH session.</p>
<h2>Why I built it</h2>
<p>Honestly? I saw <a href="https://terminal.shop">terminal.shop</a> and thought it was awesome. They sell coffee at <code>ssh terminal.shop</code> (that&#39;s the whole storefront, no website) and the first time I tried it I wanted to know if I could build something like it.</p>
<p>I do side projects like this when I need to step away from whatever I&#39;m shipping day-to-day. They&#39;re not pitches. They don&#39;t need a thesis. They&#39;re more like the way some people fix a bike on a Saturday: sit down, follow whatever&#39;s pulling on your curiosity, and at the end you have a thing that didn&#39;t exist before. That&#39;s the whole reason.</p>
<p>So this was that. A few evenings poking at <a href="https://ratatui.rs">ratatui</a> and <a href="https://docs.rs/russh">russh</a> to see what it would take to make <code>ssh agnelnieves.sh</code> actually work. Turns out: less than I expected. The hardest parts were the ones I didn&#39;t see coming, which is what makes any of this fun.</p>
<h2>The architecture</h2>
<p>One binary. Two modes. One render path.</p>
<pre><code>            ┌─────────────────────────────┐
            │     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
</code></pre>
<p>The pieces:</p>
<ul>
<li><strong>TUI:</strong> <a href="https://ratatui.rs">ratatui</a> draws everything: header, ticker, ASCII banner, projects list, blog reader. The render function lives in <code>src/render.rs</code> and is identical whether the binary is running on your laptop or whether you&#39;re seeing it over SSH.</li>
<li><strong>State machine:</strong> <code>App</code> in <code>src/app.rs</code> owns screen, scroll position, list selection, ticker animation, and async-fetch results funneled through an <code>mpsc::Receiver</code>.</li>
<li><strong>Data:</strong> zero content is bundled. The CLI fetches live from <code>/feed.json</code>, <code>/api/blog/[slug]/raw</code>, <code>/api/projects</code>, and <code>/api/site.json</code> on this site, cached for an hour at <code>~/.agnel/cache/</code>. So a new blog post here shows up in your terminal immediately without redeploying anything.</li>
<li><strong>SSH server:</strong> <a href="https://docs.rs/russh">russh 0.60</a> in <code>src/serve.rs</code>. One tokio task per session.</li>
</ul>
<h2>The decision that made it easy: don&#39;t fork a PTY</h2>
<p>Most &quot;expose a TUI over SSH&quot; guides suggest spawning a pseudo-terminal and <code>exec</code>-ing the TUI binary inside it. That&#39;s how <code>mosh</code>, <code>tmux</code>, and Go&#39;s <a href="https://charm.sh/blog/wish/"><code>wish</code></a> library (the one terminal.shop uses) handle things. It works, but it&#39;s heavier:</p>
<ul>
<li>Adds a libc dependency (<code>forkpty</code>)</li>
<li>Forks a process per connection (more memory, slower spawn)</li>
<li>Hard to share state between the SSH layer and the app</li>
</ul>
<p>Russh&#39;s <code>ratatui_app</code> example showed a cleaner path: implement a custom <code>std::io::Write</code> backend that funnels bytes through russh&#39;s <code>Handle::data(channel, bytes)</code> API. Ratatui doesn&#39;t know it&#39;s writing to an SSH channel instead of stdout. No fork, no PTY, just bytes:</p>
<pre><code class="language-rust">struct TerminalSink {
    sender: UnboundedSender&lt;Vec&lt;u8&gt;&gt;,
    sink: Vec&lt;u8&gt;,
}

impl std::io::Write for TerminalSink {
    fn write(&amp;mut self, buf: &amp;[u8]) -&gt; std::io::Result&lt;usize&gt; {
        self.sink.extend_from_slice(buf);
        Ok(buf.len())
    }
    fn flush(&amp;mut self) -&gt; std::io::Result&lt;()&gt; {
        if !self.sink.is_empty() {
            let _ = self.sender.send(std::mem::take(&amp;mut self.sink));
        }
        Ok(())
    }
}
</code></pre>
<p>A background tokio task drains the channel and pushes bytes back to the client through russh. Each session gets its own <code>App</code> and its own <code>Terminal&lt;CrosstermBackend&lt;TerminalSink&gt;&gt;</code>. The lock graph stays tiny: an <code>Arc&lt;Mutex&lt;SessionState&gt;&gt;</code> per session, plus a <code>HashMap</code> of all sessions for cleanup.</p>
<p>Inputs go the other direction. Russh hands raw bytes to my <code>data()</code> callback, and a small parser turns them into the same <code>crossterm::event::KeyEvent</code> values the local TUI uses. That meant zero changes to <code>App::handle_key</code>:</p>
<pre><code class="language-rust">fn parse_keys(buf: &amp;[u8]) -&gt; Vec&lt;KeyEvent&gt; {
    // 0x03 → Ctrl+C, 0x1b[A → Up, 0x1b alone → Esc,
    // printable ASCII → Char(b), etc.
}
</code></pre>
<p>Window resize hooks into the same place. <code>window_change_request</code> calls <code>terminal.resize(rect)</code>, and ratatui redraws on the next 50 ms tick.</p>
<p>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.</p>
<h2>What I learned about hosting</h2>
<p>A few things I didn&#39;t expect.</p>
<p><strong>1. Fly trial orgs block dedicated IPv4, and SSH needs IPv4.</strong> For HTTP, Fly&#39;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&#39;t allocate one. Pure-IPv6 isn&#39;t a real option, either: most US residential ISPs still don&#39;t have native IPv6 SSH paths. Adding a card was the unlock.</p>
<p><strong>2. The SSH host key has to persist.</strong> If the container generates a fresh Ed25519 key on every boot, every returning visitor gets <code>REMOTE HOST IDENTIFICATION HAS CHANGED</code> warnings after the next deploy. The fix is a 1 GB Fly volume mounted at <code>/data</code>, with <code>--host-key /data/ssh_host_key</code>. First boot generates and writes the key; every boot after just reads it.</p>
<p><strong>3. Trial machines auto-stop after 5 minutes.</strong> That&#39;s how the Fly free tier nudges you toward billing. Once you&#39;re paying, set <code>auto_stop_machines = &quot;off&quot;</code> and <code>min_machines_running = 1</code> if you want a long-running service.</p>
<p><strong>4. <code>fly proxy</code> is fine for HTTP, weird for raw SSH.</strong> I burned half an hour on <code>Connection reset by peer</code> through <code>fly proxy 12222:2222</code> before realizing I should just <code>bash /dev/tcp/127.0.0.1/2222</code> from inside the machine via <code>flyctl ssh console</code>. The SSH server was healthy the whole time; the proxy path was the problem.</p>
<h2>ANSI Shadow beats slant</h2>
<p>The first banner I shipped used figlet&#39;s <code>slant</code> font. It looks fine in a docs file. At terminal width with full-block rendering, it falls apart into disconnected diagonals.</p>
<p>Swapped to <a href="https://patorjk.com/software/taag/#p=display&f=ANSI%20Shadow&t=AGNEL%20NIEVES">ANSI Shadow</a>, the same boxy block font terminal.shop and <a href="https://charm.sh">charm.sh</a> use. Six rows tall, about 93 columns wide, reads instantly. Probably the kind of thing I&#39;d have gotten right the first time if I&#39;d looked at more references before opening figlet.</p>
<h2>Visitor #N</h2>
<p>One fun thing I added. Look at the bottom-left of the footer when you connect: it says <code>visitor #N</code>. 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 <code>channel_open_session</code>, and persists across deploys (host-key lesson applied).</p>
<p>Implementation is about thirty lines. An <code>AtomicU64</code> loaded from the file at startup, a <code>spawn_blocking</code> write after each <code>fetch_add</code>. The file is just a base-10 number with no header, so <code>cat /data/visitor_count</code> is the dashboard.</p>
<p>There&#39;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.</p>
<h2>The stack</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Tech</th>
</tr>
</thead>
<tbody><tr>
<td>TUI</td>
<td>ratatui 0.29, crossterm 0.28</td>
</tr>
<tr>
<td>SSH</td>
<td>russh 0.60</td>
</tr>
<tr>
<td>HTTP client</td>
<td>ureq 2 (sync, plenty for this)</td>
</tr>
<tr>
<td>Async runtime</td>
<td>tokio 1</td>
</tr>
<tr>
<td>CLI args</td>
<td>clap 4</td>
</tr>
<tr>
<td>Host</td>
<td>Fly.io (<code>iad</code>, shared-cpu-1x, 256 MB, 1 GB volume)</td>
</tr>
<tr>
<td>DNS</td>
<td>Vercel Domains (A + AAAA on the apex)</td>
</tr>
<tr>
<td>Binary size</td>
<td>~3 MB release (macOS arm64), 4.3 MB (Linux x86_64)</td>
</tr>
<tr>
<td>Image size</td>
<td>28 MB (Debian bookworm-slim runtime)</td>
</tr>
<tr>
<td>Cost</td>
<td>$2/mo for the dedicated IPv4 + free tier for the rest</td>
</tr>
</tbody></table>
<h2>What&#39;s next</h2>
<ul>
<li><strong>Pretty errors and loading states.</strong> Today they say <code>Loading…</code> which is functional but boring.</li>
<li><strong>Project deep-links.</strong> 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.</li>
<li><strong>Cross-platform release binaries.</strong> The current install path is <code>cargo install --path cli/</code>. Homebrew tap + release artifacts for macOS and Linux is a Saturday morning.</li>
</ul>
<h2>Try it</h2>
<pre><code class="language-bash">ssh agnelnieves.sh
</code></pre>
<p>If you want to build your own version, the <a href="/guides/ssh-terminal-portfolio.md">guide</a> 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, <a href="/connect">let me know</a>.</p>
<hr>
<p><em>Built by <a href="/about">Agnel Nieves</a>, a design engineer with 15+ years across product, design systems, and crypto. More writing on <a href="/blog">the blog</a>.</em></p>
]]></content:encoded>
      <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>Rust</category>
      <category>Ratatui</category>
      <category>SSH</category>
      <category>Fly.io</category>
      <category>Design Engineering</category>
      <category>Side Projects</category>
    </item>
  </channel>
</rss>