← All posts

Serving a portfolio over SSH

Giving this portfolio a second front end over SSH — Go, Wish and Bubble Tea in front of the same Laravel app — turned out to hinge on three unglamorous decisions: never rotate the host key, treat the visitor's key fingerprint as their identity, and be willing to give up port 22.

This site is a Laravel application with an Inertia and React front end. It is being given a second front end that shares none of that code: a full-screen terminal application you reach by typing ssh veltix.sh. Same projects, same posts, same publishing rules, no browser.

The terminal side is a Go binary built on Charm's Wish for the SSH server and Bubble Tea for the application itself, with Lip Gloss doing the styling. It runs on the same host as Laravel and talks to it over loopback.

Two front ends, one source of truth

The tempting shortcut is to point the Go process straight at the database. It would work, and it would fork the rules. The published() scope, the "published but never scheduled means invisible" behaviour, the ordering — all of it would have to be reimplemented in Go, and would drift the first time either side changed.

So the TUI gets a read-only JSON API instead, mounted under /api/tui/v1 and guarded by a shared bearer token. It is the only API surface the application exposes, and Laravel remains the only thing that touches the database.

The endpoints are shaped for a single consumer rather than for generality:

  • manifest returns counts, a message of the day, and a revision hash derived from the newest updated_at across published projects and posts.
  • projects and posts return whole collections, unpaginated. The TUI holds the list in memory and pages through it locally; one request beats a round trip per screenful over an SSH channel.
  • The detail endpoints are fetched on demand. Bodies are large and most are never opened in a session.

The Go side polls only manifest on a one-minute ticker and re-fetches the collections when the revision moves. Steady state is one small request a minute regardless of how much content exists. Sessions read a snapshot under a read lock and never wait on Laravel, so a deploy or a php-fpm blip degrades to serving slightly stale content with a marker in the status bar, rather than an error screen.

The host key is the whole trust model

This is the detail that would have bitten hardest.

The first time someone runs ssh veltix.sh, their client records the server's host key in known_hosts. If that key ever changes, they do not get a prompt. They get REMOTE HOST IDENTIFICATION HAS CHANGED, and OpenSSH refuses to connect until they manually edit the file. A rotated host key hard-blocks every returning visitor, and it looks exactly like an attack, because that is what the check is for.

Wish will happily generate a key for you if the path is empty, which is a lovely developer experience and a trap in production. Combine it with atomic symlink deploys, where each release is a fresh directory, and a key stored inside the release is a key that changes on the next push.

The binary therefore keeps its key outside the deploy tree, at /etc/veltix-tui/ssh_host_ed25519_key, and refuses to start if it is missing:

if _, err := os.Stat(*hostKey); err != nil {
    slog.Error("host key missing — refusing to start with a generated one",
        "path", *hostKey, "err", err)
    os.Exit(1)
}

A daemon that will not start is a page. A daemon that silently generates a new identity is a slow, invisible outage for exactly the people who came back.

Identity without a signup

Every SSH client offers a public key before it offers anything else. Wish hands it to you, and its SHA256 fingerprint is a stable identifier the visitor already has and never had to create:

if pk := s.PublicKey(); pk != nil {
    sess.Fingerprint = gossh.FingerprintSHA256(pk)
    sess.Anonymous = false
}

Both the public-key and keyboard-interactive callbacks return true. Nobody is being authenticated against anything — this is a public endpoint. A key gets you a persistent identity; no key gets you an anonymous session. That is the whole account system, and it means a leaderboard position can survive between visits without a form, a password or an email address.

Giving up port 22

ssh veltix.sh with no port flag means the TUI has to own 22, and something was already there. Administrative sshd moved to 7283 first, which is a change you make carefully and with a second session open.

The part that is easy to forget is that the deploy pipeline connects over SSH too. Moving the daemon without wiring DEPLOY_PORT into the workflow breaks every deploy on the next push.

With 22 given away to a public service, the server is locked to exactly one capability. Sessions without a PTY are rejected, so no exec, no scp, no subsystems. Local and reverse port forwarding callbacks both return false, and the subsystem handler map is empty. An open relay on port 22 becomes somebody else's spam infrastructure within days.

Try the result yourself.

ssh veltix.sh

Contact

Build something that lasts.

Got a Laravel project, a WooCommerce mess or an integration that needs untangling? Tell me what's breaking.

© 2026 Veltix · Kristen LõokeGitHub · Tallinn