The ingress pointed at scottyah-tls, a secret hand-copied between namespaces and absent from this one, so Traefik fell back to a self-signed certificate. That is invisible behind Cloudflare until the SSL mode is set to Full (strict), which then fails with a 526. Issue into the namespace over DNS-01 instead. Nothing to copy, and the renewal is no longer a thing anyone has to remember. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fit
A 75-day challenge, five things a day: the workout, the protein, the calories, the water, and three lines of gratitude.
Single Go binary, no dependencies, one JSON file on a volume. Auth is Traefik's basic auth at the ingress; the app itself has no login.
What it does
- One page per day. Open it, tick the workout, type three numbers, write three lines. Ticking a box saves in the background — no page reload.
- Targets, not just numbers. Protein and water fill towards a floor to reach; calories fill towards a ceiling not to cross. A day is complete when all five are hit.
- The card. Seventy-five tiles, fifteen across, each filling from the bottom by how many of that day's five landed. A missed day stays a gap — it does not reset the count.
- Yesterday is editable. Back-arrow to any past day and fill it in. You cannot log the future.
- Export and restore from the settings page, so the log is never only in one place.
- Installable. Add to the home screen and it opens without browser chrome.
Design
Municipal pool tile: a pale aqua ground with grout lines, deep navy-teal ink, chlorine blue for anything that fills, and the orange of backstroke flags for the day you are standing on. It follows the system light/dark setting.
Each measurable is a band that is its own meter — the fill is the band, not a bar next to it — and the track ends where the target is. Go past a ceiling and the whole band turns hot.
Typography is Avenir Next Condensed for display and the system UI and monospace faces for everything else, so there are no web fonts to serve.
Running it locally
Needs Go 1.26. No database.
export DATA_PATH="$PWD/data/fit.json"
export TZ_NAME="America/Los_Angeles"
go run ./cmd/server
Then open http://localhost:8080. The first load asks for the start date and the targets; everything after that is the day page.
go test ./...
Configuration
| Variable | Required | Notes |
|---|---|---|
DATA_PATH |
no | The log file. Default data/fit.json, relative to the working directory. The directory is created if missing. |
TZ_NAME |
no | Default America/Los_Angeles. Decides when the day rolls over; a wrong value files entries against the wrong date. |
BASE_URL |
no | Public origin. Default http://localhost:8080. |
ADDR |
no | Listen address, default :8080. |
The start date, the length, and the three targets are not environment
variables. They live in the data file and are edited from /settings, so
changing a target does not need a deploy.
Deploying
Follows the same shape as the other apps in ~/dev: push to main, Gitea
Actions builds with buildah, pushes to Harbor, and applies k8s.yaml.
Before the first deploy, create the basic-auth secret. This is the only thing guarding the log, so pick a real password:
htpasswd -nbB scott 'a-real-password' > /tmp/users
kubectl create namespace fit
kubectl create secret generic fit-basic-auth -n fit --from-file=users=/tmp/users
rm /tmp/users
Point fit.scottyah.com at the cluster. The certificate is issued by
cert-manager through the letsencrypt-prod-cloudflare cluster issuer, which
solves a DNS-01 challenge at Cloudflare and writes fit-tls into this
namespace. Nothing is copied in and nothing is renewed by hand.
Cloudflare's SSL mode should be Full (strict). Flexible carries the basic-auth password to the origin in the clear, and it rides on every request.
To pull a copy of the log without the browser:
kubectl exec -n fit deploy/fit-dep -- cat /data/fit.json > fit.json
Notes on how it's built
- The file is the database. Seventy-five rows for one person do not justify Postgres. The file is read into memory at boot and written back on every change: temp file in the same directory, fsync, rename, fsync the directory. A crash mid-write leaves the previous file intact rather than half of a new one.
- A file that does not parse is a fatal error, not a fresh start. Silently
replacing an unreadable log with an empty challenge would destroy the one
thing here that cannot be recreated. Boot also leaves a
.bakcopy of whatever last parsed. - Blank is not zero. The three numbers are pointers, so a day with no calories entered does not read as a perfect calorie day.
- Dates are local dates, everywhere. No time of day is stored. Arithmetic parses them in UTC so a daylight-saving change cannot make a day 23 or 25 hours long and shift the numbering.
- Nothing needs JavaScript. The form posts and the page reloads. With JavaScript the meters move as you type and the save happens in the background, flushed when the tab goes away.
- Static assets are content-hashed.
/static/app.css?v=…changes whenever the file does, so a deploy is never stuck behind a cached stylesheet. - Writes must come from this site. Browsers attach basic-auth credentials
to cross-origin form posts, so any other page could otherwise post to
/importand replace the log. EveryPOSTchecksSec-Fetch-Site.
Things deliberately left out
- Accounts, sessions and passwords in the app. One person, one basic-auth prompt at the edge.
- A reset rule. Missing a day leaves a gap; it does not send you back to day 1.
- Weights, reps, photos, and measurements. This tracks whether the five things happened, not what the workout was.