An SSH-accessible terminal portfolio. Connect over SSH and you land inside an interactive Textual TUI β no browser, no account, no shell.
ssh ssh.athxrvc.co.ukThat's the entire pitch: drop a stranger into a tiny sandboxed terminal product and walk them around.
Most portfolios are websites, and I can't make good websites. This one is a terminal product experience:
- Connect over SSH
- See ASCII branding, a portrait, and a bio
- Navigate sections with arrow keys
- Never get a real shell, command execution, or file transfer
It's hosted on a tiny VPS and replies to anyone.
- π SSH-native. Any client works β
ssh, Termius, Blink, PuTTY. - β¨οΈ Keyboard driven. β / β / β / β to move, Enter to open, Esc to back,
qto quit. - π¨ ASCII-first. Custom portrait and name banner; sparkles twinkle around the banner.
- π Sandboxed. Anonymous auth, no shell channel, no SFTP, no exec. Every visitor gets a fresh isolated TUI in their own PTY.
- π Content as data. All copy lives in TOML files under
src/portfolio/data/. Adding a section is a new file plus one menu entry. - πͺΆ Lightweight. Single Python process, no database, no frontend build.
- Python 3.11+
uvfor deps and script entrypoints
Great for iterating on screens, content, or themes without an SSH server in the way:
uv sync
uv run portfolio-tuiThis runs the Textual app in your current terminal.
uv sync
uv run portfolio-ssh
ssh -p 2222 127.0.0.1The first run auto-generates an Ed25519 host key at keys/ssh_host_key
(ignored by git). Local mode binds to 127.0.0.1:2222 by default β override
with the PORTFOLIO_HOST / PORTFOLIO_PORT env vars (see
Configuration).
Each SSH connection maps to its own pseudo-terminal running the Textual TUI. The server doesn't expose a shell β it only knows how to spawn the portfolio process inside a PTY and pipe bytes back and forth.
flowchart LR
A["Visitor runs<br/>ssh ssh.athxrvc.co.uk"] --> B["AsyncSSH server"]
B -->|"spawns a PTY,<br/>forwards bytes"| C["Textual app"]
C -->|"renders via"| D["Rich"]
E[(src/portfolio/data/*.toml)] --> C
C -. "reads copy from" .-> E
Key design points:
- Anonymous SSH.
PortfolioSSHServer.begin_authreturnsFalse, so no credentials are required. Password and public-key auth are explicitly disabled. - PTY-per-session. Every visitor gets an isolated pseudo-terminal running
the TUI.
SIGWINCHfrom terminal resizes is forwarded into the PTY so the layout reflows live. - No shell, no exec. The server registers only a
process_factoryβ no SFTP subsystem, no shell channel, no command execution. Visitors can only run the portfolio. - Raw bytes, no encoding. Bytes pass between the SSH channel and the
PTY unchanged (
encoding=Noneon the AsyncSSH server), so Rich/Textual terminal escape sequences round-trip cleanly. - Content as data. All copy lives in TOML under
src/portfolio/data/. Adding a new section is a new file plus one menu entry β no code changes.
| Layer | Tool | Why |
|---|---|---|
| Language | Python 3.11+ | Single runtime for server and UI |
| SSH server | AsyncSSH | Async Pythonic SSH with PTY + process_factory |
| Terminal UI | Textual | Screen-based TUI, reactive state, stylesheets |
| Rendering | Rich | Styled text, OSC 8 hyperlinks, OSC 52 clipboard |
| Content format | TOML | Human-editable, diffable, living in version control |
| Tooling | uv | Dependency management and console scripts |
src/portfolio/
__init__.py package marker + version
__main__.py `python -m portfolio` β starts the SSH server
server.py AsyncSSH server, host-key generation, PTY bridge
app.py Textual App root; pushes HomeScreen on mount
tui.py `portfolio-tui` β runs the TUI in this terminal
content.py TOML loader, dataclasses, in-memory cache
theme.py Color constants (BG / FG / DIM / ACCENT / TITLE)
theme.tcss Textual stylesheet
art/
portrait.txt ASCII portrait shown on the home screen
name.txt ASCII name banner (sparkles are drawn programmatically)
screens/
__init__.py
home.py Portrait + bio + section menu (sparkle animation)
listing.py Generic list screen; arrow-key nav, opens DetailScreen
detail.py Detail screen for a single item
contacts.py Selectable contacts; OSC 8 hyperlinks + OSC 52 clipboard fallback
data/
home.toml Profile + home-screen menu (drives everything else)
creations.toml The Creations section
reflections.toml The Reflections section
contacts.toml Contact list
Most edits won't touch Python β see Editing content.
All visible copy is data. You shouldn't need to touch code to:
- change your bio
- add or remove a section
- add or remove a contact
- swap the ASCII art
This file defines the bio and drives every other section via the menu.
[profile]
handle = "atharva"
intro = ["Short tagline line 1", "Short tagline line 2"]
about = ["About line 1", "About line 2", "About line 3"]
closing = ["", "Prompt to keep exploring"]The home sparkles are drawn by _glitter(name_art, frame) in
screens/home.py; the actual letters come from art/name.txt.
The menu block controls the bottom row of the home screen:
[[menu]]
label = "Creations"
kind = "listing" # loads data/creations.toml as a list of items
key = "creations"
[[menu]]
label = "Contacts"
kind = "contacts" # loads data/contacts.toml as a list of links
key = "contacts"To add a new section, drop a data/<key>.toml file and add an entry here β
no code changes needed.
Listing sections are broken into groups, each containing items:
title = "Creations"
[[groups]]
label = "projects"
[[groups.items]]
title = "Terminal Based Portfolio"
url = "ssh.athxrvc.co.uk"
meta = "2025"
body = [
"The portfolio you're browsing right now,",
"served over SSH.",
"Built in Python with Textual for the UI",
"and asyncSSH handling the server side.",
]Each item has title, optional meta (small grey subline under the title),
optional url (rendered with a "View β" arrow on the detail screen), and a
list-only body of paragraphs. Group labels are optional; omit them by
setting label = "".
[[contacts]]
label = "LI"
value = "linkedin.com/in/atharva-c-1ba373277/"
[[contacts]]
label = "GH"
value = "github.com/athxrvc"
url is auto-derived from value by content._normalize_url:
github.com/athxrvcβhttps://github.com/athxrvcme@example.comβmailto:me@example.comhttps://.../mailto:...pass through unchanged
On the contacts screen, Enter tries the browser first, then falls back to clipboard via OSC 52 (works through SSH). The status bar shows which one worked.
Drop two files into src/portfolio/art/:
name.txtβ wordmark shown on the home screen (anything up to ~24 cols wide)portrait.txtβ left column on the home screen
Both are read verbatim and rendered through Rich, so monospace alignment in your editor is what the user sees.
| Knob | Where | Default |
|---|---|---|
| Listen address | PORTFOLIO_HOST env var |
127.0.0.1 |
| Listen port | PORTFOLIO_PORT env var |
2222 |
| Host key path | PORTFOLIO_HOST_KEY env var |
keys/ssh_host_key |
| Colours | src/portfolio/theme.py (BG / FG / DIM / ACCENT / TITLE) |
muted dark + teal |
| Stylesheet | src/portfolio/theme.tcss |
β |
| Wordmark / portrait | src/portfolio/art/*.txt |
β |
| Sparkle effect | src/portfolio/screens/home.py (SPARKLES, SPARKLE_STYLES, GLITTER_INTERVAL) |
β |
The console-script entrypoints are defined in pyproject.toml:
| Command | What it does |
|---|---|
portfolio-ssh (or python -m portfolio) |
Start the AsyncSSH server |
portfolio-tui |
Launch the Textual app directly in the current terminal |
| Key | Where | Action |
|---|---|---|
| β / β | Home | Move between menu sections |
| β / β | Home | Move between menu sections |
| β / β | Listing / Contacts | Move between items |
| Enter | any | Open the highlighted thing |
| Esc | Listing / Detail / Contacts | Go back |
q |
any | Quit |
The live instance at ssh.athxrvc.co.uk is a single Ubuntu VPS running
behind systemd. The official SSH public port serves the portfolio only; admin
access is on a separate non-default port.
Public TCP/22 β portfolio-ssh (the TUI)
Private TCP/22222 β OpenSSH (admin/maintenance only)
Current infra details:
- VPS: FastHost (Ubuntu)
- Domain:
athxrvc.co.uk - DNS:
ssh.athxrvc.co.ukβ77.68.125.29 - Auth: Anonymous SSH for the portfolio; key-only OpenSSH on the admin port.
Place at /etc/systemd/system/portfolio-ssh.service (adjust WorkingDirectory
and User to match your install location):
[Unit]
Description=portfolio-terminal SSH server (public, anonymous)
Documentation=https://github.com/athxrvc/portfolio-terminal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=portfolio
WorkingDirectory=/opt/portfolio-terminal
Environment=PORTFOLIO_HOST=0.0.0.0
Environment=PORTFOLIO_PORT=22
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/portfolio-terminal/.venv/bin/portfolio-ssh
Restart=on-failure
RestartSec=5
TimeoutStopSec=10
# Bind to a low port without running as root.
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/portfolio-terminal/keys
[Install]
WantedBy=multi-user.targetThen:
sudo systemctl daemon-reload
sudo systemctl enable --now portfolio-ssh
sudo systemctl status portfolio-ssh # should show "active (running)"
ssh ssh.athxrvc.co.uk -p 22 # should land in the TUISSH is a binary protocol β don't put a TLS reverse proxy in front. Expose it directly and let the host firewall / cloud security group split traffic:
# UFW example
sudo ufw allow 22/tcp # public TUI
sudo ufw allow from <your admin IP> to any port 22222 # admin SSH only
sudo ufw enable- Provision an Ubuntu VPS with a public IP.
- Clone the repo to
/opt/portfolio-terminal. uv sync(creates.venv).- Generate or mount a persistent host key in
/opt/portfolio-terminal/keys/ssh_host_key(the app will create one on first launch if absent β verify withls -la keys/afterwards). - Open the firewall (see above).
- Point
ssh.athxrvc.co.ukDNS A-record at the VPS IP. - Enable the systemd unit.
ssh ssh.athxrvc.co.ukfrom your laptop and confirm the TUI renders.
OSError: [Errno 98] address already in useon startup β Port 22 (or what you set inPORTFOLIO_PORT) collides. Stop the conflicting service or change the env var.UNPROTECTED PRIVATE KEY FILE! ... keys/ssh_host_keyβ AsyncSSH refuses private keys that aren't0600. Runchmod 600 keys/ssh_host_key.- Visitor sees
This portfolio needs an interactive terminal ...β Their SSH client didn't request a PTY (e.g. ranssh ssh.athxrvc.co.uk cat /etc/hostname). Plainssh ssh.athxrvc.co.ukworks. - Garbled layout remotely but fine locally β Likely a
TERMmismatch. TryTERM=xterm-256color ssh ssh.athxrvc.co.uk. Textual probes for modern terminal features on launch. - Resizing the SSH window doesn't reflow β Most clients forward
SIGWINCHautomatically. PuTTY does; some embedded clients don't. - Host key changed after redeploy β Persist
keys/across deploys (it's gitignored for a reason: keep it out of the repo, but back it up off-host). - For screenshots / recordings β Use
asciinema,script, or just copy from a wide terminal. The portrait + name banner need roughly 100Γ32 to look right.
Personal portfolio; all rights reserved. Feel free to crib the infrastructure (AsyncSSH + PTY bridge + Textual screens + TOML content pipeline) for your own SSH-served portfolio.