I built a portfolio you can visit over SSH - here’s how

You’ve probably seen developer portfolios in every form: polished React websites, minimalist one-pagers, maybe even a Notion page.

I wanted to build something different.

So I built a portfolio you can access directly from your terminal:

ssh ssh.mendisofficial.me

No browser. No URL bar. No login screen. Just SSH in and explore.

When someone connects, they get an interactive TUI (Terminal User Interface) with:

  • my bio
  • projects
  • skills and interests
  • contact details

with color, keyboard navigation, and scrolling.

The SSH portfolio TUI running in a terminal, showing the main menu with keyboard navigation

Inspiration

This project was heavily inspired by Saai Syvendra and his SSH portfolio concept.

His original post: ssh ssh.syvendra.com – if you are a techy, you’ll love this

Seeing that made me think: I should build my own version and make it fully production-ready.

The idea

The flow is simple:

  1. Someone runs ssh ssh.mendisofficial.me
  2. Instead of a shell, they get my portfolio TUI
  3. They navigate using arrow keys and Enter
  4. They press q to quit

No auth prompts, no passwords, no setup friction.

Tech stack

I built this in Go using the Charm ecosystem:

  • Wish for SSH server infrastructure
  • Bubble Tea for the terminal app architecture
  • Lip Gloss for styling

My key dependencies:

require (
    github.com/charmbracelet/bubbletea v1.3.10
    github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894
    github.com/charmbracelet/wish v1.4.7
    github.com/charmbracelet/lipgloss v1.1.0
)

Architecture overview

The app is currently implemented in a single main.go file with three core pieces.

1) SSH server layer (Wish)

Wish accepts incoming SSH connections and hands each session to Bubble Tea:

func main() {
    port := os.Getenv("SSH_PORT")
    if port == "" {
        port = "22"
    }

    s, err := wish.NewServer(
        wish.WithAddress("0.0.0.0:"+port),
        wish.WithHostKeyPath(".ssh/term_info_ed25519"),
        wish.WithMiddleware(
            bm.Middleware(teaHandler),
        ),
    )
    // start server + graceful shutdown logic
}

SSH_PORT is environment-driven, so local and production behavior stay flexible.

2) TUI layer (Bubble Tea)

The model tracks:

  • active page
  • cursor position
  • scroll offset
  • terminal dimensions
  • styles

The update function handles keybindings (arrows, Enter, q/Esc), and the view function renders each page.

Current sections in the portfolio:

  • Main Menu
  • About Me
  • Projects
  • Skills & Interests
  • Contact

3) Styling layer (Lip Gloss)

Lip Gloss gives the TUI visual identity: color, hierarchy, spacing, and legibility.

The most important bug I hit (and how I fixed it)

Locally, everything looked perfect. Over SSH, all colors disappeared.

The reason was subtle:

  • global lipgloss.NewStyle() uses the default renderer (os.Stdout)
  • SSH sessions need per-session rendering because each client terminal has different capabilities

The fix was to create a renderer from the SSH session:

func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
    renderer := bm.MakeRenderer(s)
    return initialModel(renderer), []tea.ProgramOption{tea.WithAltScreen()}
}

Then I moved all styles into a per-session style bundle:

type styles struct {
    renderer    *lipgloss.Renderer
    titleStyle  lipgloss.Style
    accentStyle lipgloss.Style
}

func newStyles(r *lipgloss.Renderer) styles {
    return styles{
        renderer:    r,
        titleStyle:  r.NewStyle().Bold(true).Foreground(cyan),
        accentStyle: r.NewStyle().Foreground(green).Bold(true),
    }
}

If you’re building with Wish + Lip Gloss, this is the key takeaway: avoid global style creation when rendering over SSH.

Deployment on a VPS

I deployed this on a VPS.

Step 1: move real SSHD off port 22

Since the portfolio app needs port 22 for public access, system SSHD was moved to a high port in sshd_config:

Port 2222

Important safety rule: keep your current SSH session open, allow the new port in both the instance firewall and VPS security rules, then test from a second terminal before restarting SSHD.

Step 2: run app with systemd

I created a systemd unit to run the app continuously and restart on failure:

[Unit]
Description=SSH Portfolio TUI Application
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/ssh-portfolio
ExecStart=/opt/ssh-portfolio/ssh-portfolio
Environment=SSH_PORT=22
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Step 3: DNS

A simple A record points ssh.mendisofficial.me to the VPS public IP.

CI/CD with GitHub Actions

I wanted deploys to be one-step: push to main, then auto-build and restart remotely.

name: Deploy SSH Portfolio

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_SSH_PORT }}
          script: |
            set -e
            export PATH=$PATH:/usr/local/go/bin
            sudo git config --global --add safe.directory /opt/ssh-portfolio
            cd /opt/ssh-portfolio
            sudo git fetch origin main
            sudo git reset --hard origin/main
            sudo go build -buildvcs=false -o ssh-portfolio .
            sudo systemctl restart ssh-portfolio

Secrets used

  • VPS_HOST
  • VPS_USER
  • VPS_SSH_KEY
  • VPS_SSH_PORT

I used a dedicated Ed25519 deploy key generated on the server and stored the private key in GitHub Actions secrets.

Pipeline issues I debugged

I ran into a few real-world failures before it became stable.

1) Dubious ownership error

fatal: detected dubious ownership in repository at '/opt/ssh-portfolio'

Cause: repo owner and deploy user were different. Fix: safe.directory.

2) Go build VCS stamping failure

error obtaining VCS status: exit status 128

Cause: Go attempted to gather Git metadata during build. Fix: go build -buildvcs=false.

3) Action looked successful even on failed commands

Cause: remote script continued after failures. Fix: add set -e.

4) Permission denied on .git/FETCH_HEAD

error: cannot open .git/FETCH_HEAD: Permission denied

Cause: permission mismatch in repo files. Fix: run Git/build commands with sudo in this setup.

Lessons learned

  • SSH apps are a fantastic way to stand out as a developer.
  • The per-session Lip Gloss renderer is essential for proper SSH color output.
  • A VPS is all you need — the resource footprint is tiny.
  • Even for personal projects, CI/CD saves time and avoids manual drift.
  • SSHD port changes are risky, so always stage and test them carefully.

Try it

ssh ssh.mendisofficial.me

Use arrow keys to navigate, Enter to open sections, and q to quit.

If you’re a developer who likes building unusual things, I highly recommend trying this. The Charm toolchain makes it much easier than it looks, and shipping a portfolio in the terminal is incredibly satisfying.

I built a portfolio you can visit over SSH - here’s how - Chathusha Mendis