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:

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:

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)