___ _____ ____ _ _ _ ____
/ __|_ _| _ \ / \ | \ | | _ \
\__ \ | | | |_) | / _ \ | \| | | | |
___) || | | _ < / ___ \| |\ | |_| |
|____/ |_| |_| \_\/_/ \_\_| \_|____/
a personal site that is, mostly, a lie: the web page you're on is just the lobby. the actual site lives inside an SSH session. this page explains how the whole thing is wired, how to run it, how to test it, and how to ship it.
# what this is
one Go binary, two front doors. a single process opens two listeners:
- SSH —
ssh strand.wfhands every session a Bubble Tea TUI (served by Wish): an animated boot, a menu (about / projects / contact / guestbook / links), a persisted guestbook, a visitor counter, and a hidden easter egg (typesudoat the menu). - web — this site.
net/http+html/template+ HTMX. every command you type in the lobby terminal POSTs to/cmdand the server returns an HTML fragment that gets appended to the screen. it exists mostly to point you at the SSH door.
both doors read from one content file and share a SQLite database (the guestbook, the visit count, and a stable SSH host key — persisting that key is what stops returning visitors getting a scary “host key changed” warning after a redeploy).
# why SSH (and why not Vercel)
an SSH server has to hold a live TCP connection open on a port for the whole session. serverless hosts (Vercel, Netlify, Cloudflare Pages) only run short-lived functions — they physically can't keep that socket open. so the binary runs on a small always-on host instead. the web page could live on Vercel, but since the box is already there for SSH, it serves both. one deploy, one thing to reason about.
# project layout
cmd/strand/main.go wire store + ssh + http, graceful shutdown internal/content/content.go YOUR info — name, contacts, projects (edit me) internal/sshapp/ Wish server + Bubble Tea TUI internal/web/ net/http + HTMX terminal (and this page) internal/store/ SQLite: guestbook, visits, ssh host key Dockerfile fly.toml deploy config
# make it yours first
everything personal lives in internal/content/content.go — name,
tagline, about text, contact links, projects. open it and search for
TODO. both the SSH TUI and this website render from that one file, so
you only edit in one place.
# how to start (locally)
you need Go. on this machine it's the portable build at
C:\Users\lucas\go-sdk\go, already on your PATH — a fresh terminal
will find go.
# from the repo root cd strand # one-time: pull dependencies go mod tidy # run both servers go run ./cmd/strand
that boots:
- SSH on
localhost:23234 - web on
http://localhost:8080
heads up: on this machine port 8080 is already used by a node dev server. pick another with an env var:
# PowerShell $env:HTTP_ADDR = ":8090"; go run ./cmd/strand # then open http://localhost:8090
all config is via optional env vars:
SSH_ADDR ssh listen address (default :23234) HTTP_ADDR web listen address (default :8080) DATA_DIR sqlite + host key (default ./data)
# how to test
1. the automated suite — covers the store, the web command parser, and a full walk through the TUI model (boot → menu → contact → signing the guestbook → the easter egg):
go vet ./... go test ./...
2. the web, by hand — with the server running, open the site and type
help, contact, ssh, clear. or hit
it directly:
# PowerShell
Invoke-WebRequest http://localhost:8090/cmd -Method POST -Body @{cmd='help'} | Select -Expand Content
3. the SSH side, for real — any username works, no key or password needed:
ssh -p 23234 localhost # or with a name, which becomes your guestbook signature: ssh -p 23234 lucas@localhost
navigate with ↑/↓ (or j/k),
enter to open, esc to go back, q to quit. sign the
guestbook, reconnect, and your note should still be there — that's the SQLite
store doing its job. (try sudo at the menu too.)
# how to ship it (Fly.io)
the deploy artifact is the Dockerfile — it builds a single static
binary into a tiny distroless image. you'll need a
Fly account and the CLI
(iwr https://fly.io/install.ps1 -useb | iex, then
fly auth login).
# 1. create the app (don't deploy yet); set name/region in fly.toml fly launch --no-deploy # 2. a persistent volume for the db + ssh host key fly volumes create data --size 1 --region <your-region> # 3. a DEDICATED IPv4 — required, because shared IPs only serve 80/443, not 22 fly ips allocate-v4 # ~$2/mo fly ips allocate-v6 # free # 4. ship it, then read off your IPs fly deploy fly ips list
point the domain (DNS at one.com) — in the one.com control panel, under
DNS settings for strand.wf, add:
A @ <your dedicated IPv4> AAAA @ <your dedicated IPv6>
those same records serve both the SSH door (port 22) and the web (443).
issue the web TLS cert once DNS resolves:
fly certs add strand.wf fly certs show strand.wf # wait for "Ready"
then verify it's live:
ssh strand.wf # full TUI; reconnect once — no host-key warning # open https://strand.wf # valid cert; try help, contact, ssh, clear
# shipping an update (again)
once it's live, every change after that is one command from the
strand/ directory:
fly deploy
it rebuilds the image and does a rolling release with no downtime. the
persistent volume keeps the guestbook, the visitor count, and the SSH host
key intact across deploys — so returning visitors never get a scary
“host key changed” warning. want git push to deploy?
create a token (fly tokens create deploy), store it as the repo's
FLY_API_TOKEN secret, and run flyctl deploy from a
GitHub Action on push to main.
# versioning
the release number lives in one file —
internal/version/version.go — and both front doors read
it: the web shows it in the tab bar and the version command,
the SSH TUI shows it in the fastfetch panel and the boot sequence. one bump
updates everything:
./scripts/bump.ps1 0.2.0 # edits version.go git commit -am "release v0.2.0" fly deploy
# tl;dr cheat sheet
edit internal/content/content.go (your info) deps go mod tidy run $env:HTTP_ADDR=':8090'; go run ./cmd/strand test go vet ./... ; go test ./... ssh ssh -p 23234 localhost bump ./scripts/bump.ps1 0.2.0 (release number, both sides) ship fly deploy (after launch + volume + ips, see above) update fly deploy (every change after the first deploy)