The Outrun-grade vault for your hat collection.
Headroom is a self-hosted inventory for serious hat collections. Snap a photo and Claude Vision identifies the brand, model, and colors, estimates retail price, and pulls live resale comps. The background is stripped automatically so every hat floats on a synthwave canvas. Built mobile-first, runs on a Raspberry Pi, secured for the open internet, looks like 1986.
Docs: Usage guide — the app end-to-end · Operations guide — deploy, configure, back up, upgrade, troubleshoot · CHANGELOG
Hundreds of hats in identical cases means two daily problems: "where is my light-blue one?" and "what is all of this actually worth?" Headroom answers both — perceptual color search over every photo, a location breadcrumb on every result, and three independent price signals per hat.
🧠 Identify
- Claude Vision analysis — brand, specific model, colorway, tiered colors with hex, design notes. One tool-use call per photo, prompt caching enabled. Retail price is looked up, not guessed: a table of melin's real list prices by construction (cross-checked against order history) answers first, and Claude's estimate only fills in where the table has no row.
- Works without any keys — background removal and dominant-color detection run locally (colors are read only from the hat's cutout mask, so the background can never contaminate them). Add a Google Vision key for logo-based brand detection as a fallback when Claude is unavailable.
- Automatic background removal —
rembg(ONNX, runs on a Pi) turns every photo into a transparent PNG. - Colorway catalog — harvests every "Model – Colorway" name circulating on the melinrecap resale market (including years of sold-out drops) to power autocomplete and purchase matching.
🔎 Find
- Search by color — tap a swatch (or pick any color) and hats rank by perceptual closeness (CIEDE2000) over their stored hex values, weighted by how much of the hat wears that color. A hat whose secondary color matches still surfaces, but never above a hat that is that color. Gray hats stay out of a purple search: the hue question is settled before distance is measured, because a distance metric alone will happily call charcoal "nearly purple". "Light blue" works no matter what the analyzer called it.
- Text search — multi-term AND over name, brand, model, style, condition, size, colors, and room.
- Find-it cards — every result shows the photo, the name, and where it
physically lives:
📍 Case A-012 · Office. - QR & NFC tags — print label sheets for cases and hats, or write the same URL to an NFC sticker. Scanning a hat opens a one-tap "wore it today" screen; scanning a case opens its contents.
💰 Value
- Three price signals per hat — the retail price (table first, Claude's estimate second), eBay live asking prices for comparable listings (Browse API — eBay publishes no sold history there), and a live median resale price from melinrecap's marketplace API (no scraping, no headless browser) matched to this product, condition and size — re-checked nightly without spending a Claude call.
- Real cost basis — import order line items from your purchase emails; they match to hats and record what you actually paid, and when.
- Valuation dashboard — totals, retention %, top hats by value, realized value from sales, and a wear-rotation nudge.
- Insurance-grade inventory report — print-friendly HTML → Save as PDF.
🧢 Live with it
- Rooms → Cases → Hats, or a hat straight in a room with no case — Caddies and Aviators don't fit a travel case. Per-case capacity (3 hats, or 6 beanies), type-exclusive cases, auto-sequenced display IDs.
- 3D-printable case rack — a modular, stackable, supports-free rack that
gives each Melin travel case its own slide-in bay. OpenSCAD source + STLs in
hardware/melin-rack-v3.zip(OpenSCAD + STLs- ready-to-slice
.3mfprojects); print notes inhardware/.
- ready-to-slice
- Wear tracking — one tap logs a wear; get wear counts, cost-per-wear, and a list of hats that haven't seen the sun.
- Three import paths — single photo with crop/rotate, bulk import (100 photos through a restart-surviving queue), or straight from the system share sheet (Android PWA share target; iOS Shortcut recipe included in-app).
- Disposition tracking — sold / gifted / traded / lost / trashed, soft delete with undo; sale prices feed realized value.
- Append-only activity log — every significant change, auto-pruned.
🔐 Ship it to the internet
- Accounts — first-run owner setup, argon2id passwords, revocable sessions, login rate limiting.
- Passkeys — sign in with Face ID / Touch ID (WebAuthn).
- Everything gated — the API and the photo files require login; raw API keys never leave the server (masked reads only).
- Read-only share links — show off the collection without handing out a login; revocable, expiring after 30 days unless you say otherwise.
- Guest browsing — optionally let anyone on the LAN browse read-only (no prices, no notes) without a link at all. Off by default.
- Download the collection as a zip —
index.htmlplus an images folder. Opens in any browser, works offline, nothing to host, no login. For when the person you're showing it to can't reach the app: a share link only resolves on your own network. Prices are opt-in and off by default. - One-command HTTPS — a Caddy overlay with automatic Let's Encrypt certs.
- Backups — scheduled rolling tarballs + one-click download; documented restore.
Taking a photo is the only step you do. Everything below happens on its own, and every enrichment step is allowed to fail without costing you the hat.
The upload returns immediately — it does not wait for any of this. The
photo goes on a queue and a single in-process worker drains it, because
rembg plus a full-resolution decode is the largest allocation this app
makes and a Pi will not survive several at once. The hat shows up in the grid
straight away wearing analysis_status = pending.
flowchart TD
U["POST /api/hats/:id/photo<br/>streamed · size-capped"] --> N["Pillow · HEIC→JPEG · resize<br/>kept as original_path"]
N --> Q{"queue accepted it?"}
Q -->|yes| RET["return now · pending"]
Q -->|"no worker"| INL["run inline<br/>work is never dropped"]
RET --> W[["analysis worker · one at a time"]]
W --> BG
INL --> BG
BG["rembg ONNX · transparent PNG<br/>now the canonical photo"] --> TH["320px WebP thumb<br/>what grids render"]
TH --> K{"Anthropic key set?"}
K -->|no| FB
K -->|yes| CL["Claude Vision · 1 tool-use call<br/>brand · model · colorway<br/>colors · construction · retail"]
CL -->|"API error"| FB["Fallback · mask colors<br/>+ Google Vision logo brand"]
CL -->|ok| AP["apply to the hat record"]
subgraph LOOKUPS ["price lookups · best-effort · Claude-gated"]
EB["eBay Browse API<br/>comparable listing stats"]
ME["melinrecap · Sharetribe<br/>median matched to this hat's<br/>model + condition + size"]
end
AP --> EB --> ME --> DONE[("commit · ok")]
FB --> SKIP[("commit · fallback<br/>skipped or error")]
Things worth knowing from that picture:
- The original photo is never thrown away.
rembgoutput becomes the canonical image, but the flattened JPEG is retained asoriginal_path— it is the only thing a re-cut can work from. - On upload, the price lookups are gated on Claude succeeding — both fallback branches return early, because without a model name there is nothing to look up comparables for, and guessing from the style alone would price every hat in a shape identically. After that, re-pricing is independent of analysis: a nightly sweep (and the Re-price buttons in Settings → Data) re-checks every hat that already has a model name against live asks, with no Claude call at all.
- Failure is recorded, not hidden.
analysis_statusends upok,fallback,skippedorerror, with the reason inanalysis_error, and Settings surfaces the recent ones. - eBay and Melin failures are logged and ignored. A resale API being down is not a reason to lose a hat you just photographed.
Pick how you want to reach it — every Docker mode is the base compose file plus
one front-door overlay (don't stack two). Below, C is shorthand for
docker compose -f docker-compose.yml:
| How you want to run it | Command | Reach it at | Face ID / passkeys |
|---|---|---|---|
| Default (one host) | docker compose up -d --build |
http://localhost:8000 · http://<ip>:8000 |
✅ on localhost only |
| LAN name | C -f docker-compose.mdns.yml up -d --build |
http://headroom.local:8000 |
❌ (plain HTTP) |
| LAN, port 80 | C -f docker-compose.http80.yml up -d --build |
http://headroom.local |
❌ (plain HTTP) |
| LAN, HTTPS | C -f docker-compose.https-lan.yml up -d --build |
https://headroom.local |
✅ (trust Caddy's cert once) |
| Internet | HEADROOM_DOMAIN=… C -f docker-compose.https.yml up -d --build |
https://your-domain |
✅ (Let's Encrypt) |
| Bare metal (no Docker) | ./scripts/setup.sh --no-docker → uvicorn |
http://localhost:8000 |
✅ on localhost |
| Dev (hot reload) | uvicorn --reload + npm run dev |
http://localhost:5173 |
— |
Whichever front door you pick, upgrade with the same
-fflags. Compose applies only the files you name, so a baredocker compose up -d --buildon an overlay host is a switch back to the base config, not an upgrade — the sidecar stops and the app returns to:8000.Run
./scripts/stamp-build.sh --install-hooksonce and the footer will show the running commit (v2.18.0 · build a1b2c3d) after every pull. Details in OPERATIONS.md §5.
Passkeys/Face ID need a secure context — HTTPS or localhost — so the
plain-HTTP LAN modes are password-login only. The LAN overlays use host
networking (Linux/Pi only); details for each mode follow.
# 1. Clone
git clone https://github.com/59psi/headroom.git && cd headroom
# 2. Install + start a Docker engine (skips itself if one is already running)
./scripts/setup.sh --docker-only
# 3. Build + run, attached so you can watch the first boot
docker compose up --buildStep 2 installs a complete, Docker-Desktop-free engine:
colima + docker CLI + compose/buildx
via Homebrew on macOS, native Docker Engine via apt/dnf on Linux. If
docker info already works on your machine it changes nothing. Linux:
the script adds you to the docker group — log out/in (or newgrp docker)
before step 3.
When uvicorn reports it's listening, open http://localhost:8000 — the first visit creates your owner account, then head to Settings to paste your Anthropic API key. Once it works, Ctrl-C and relaunch in the background:
docker compose up --build -d # detached; follow logs with: docker compose logs -fThe first build takes a few minutes (it pre-downloads the rembg model so your Pi doesn't have to); later builds are cached. With
-dyour terminal returns immediately while the container is still building and booting — give it a minute before declaring it broken.
Errors like
unknown shorthand flag: 'd' in -d,docker: 'compose' is not a docker command, orCannot connect to the Docker daemonall mean your Docker install is incomplete. Step 2 fixes all of them.
Putting it on the internet? Use the HTTPS overlay — Caddy sidecar with automatic Let's Encrypt certs, passkey identity configured from your domain:
HEADROOM_DOMAIN=hats.example.com \
docker compose -f docker-compose.yml -f docker-compose.https.yml up -d --buildSee the Operations guide for the full security posture.
The app advertises itself over mDNS, so devices on your network can reach it
at http://headroom.local:8000 — no IP address to remember. macOS, iOS,
and Windows 10+ resolve .local names natively; on Linux install
avahi-daemon + libnss-mdns. Rename it with HEADROOM_MDNS_HOSTNAME
(disable with HEADROOM_MDNS_ENABLED=false).
Docker needs one extra flag: multicast can't cross Docker's bridge network, so stack the mDNS overlay (host networking — Linux/Pi only):
docker compose -f docker-compose.yml -f docker-compose.mdns.yml up -d --buildHost networking only claims the ports the app actually binds (8000 here, plus UDP 5353 for the mDNS responder) — the rest of the Pi is unaffected, and other services can keep running on their own ports.
If
headroom.localwon't resolve but the raw IP works: inside a Docker host-net container the responder binds the detected LAN interface only, so it can't leak ontodocker0/vethand lose multicast (that leak is the usual cause). On a multi-homed host where auto-detection picks the wrong NIC, pin it withHEADROOM_MDNS_INTERFACE=<lan-ip>; setHEADROOM_MDNS_INTERFACE=allto fall back to advertising on every interface.GET /api/settings/mdnsreports the advertised IP and any registration error.
Prefer a clean port 80, no HTTPS? Stack the plain-HTTP overlay instead — a
Caddy sidecar serves http://headroom.local (and http://<host-ip>) on
port 80 with no certificate to trust:
docker compose -f docker-compose.yml -f docker-compose.http80.yml up -d --buildTrade-off: http:// isn't a secure context, so passkeys / Face ID aren't
offered — password login only. Use one front-door overlay at a time
(mdns → :8000, http80 → :80, or https-lan → :443).
Browsers only offer passkeys in a secure context, and Let's Encrypt can't
issue certificates for .local names — so the LAN HTTPS overlay fronts the
app with Caddy using its built-in local CA. You trust that CA once per
device; after that the LAN name gets a real padlock and Face ID sign-in
works. Password login never needed any of this.
Prerequisites: a Linux/Pi Docker host (the overlay uses host networking for mDNS), with ports 80, 443, and 8000 free on that host.
1. Start it — use this overlay instead of docker-compose.mdns.yml
(mDNS + host networking are built in):
docker compose -f docker-compose.yml -f docker-compose.https-lan.yml up -d --buildOn first boot Caddy mints a root CA and a headroom.local certificate, the
app advertises https://headroom.local over mDNS, and the passkey identity
(HEADROOM_RP_ID / HEADROOM_ORIGIN) is set to the LAN name automatically.
2. Get Caddy's root certificate onto the device. Easiest way — on the device itself, open:
http://headroom.local:8000/api/public/ca-certificate
That serves the root certificate with the right content type, so iOS offers to install it directly. It's also linked from Settings → Device → Trust this device, which only appears when a local CA exists.
Upgrading from an overlay older than 2.45? Recreate the stack (
up -dagain) before using that URL. It is served from a certificate thecaddy-ca-exportservice copies out of Caddy's PKI, and without that service the endpoint returns 404 — Caddy's PKI is0700 root, and the app container runs as a non-root user, so it cannot read the file in place.
Or copy it off the host manually:
# The `caddy` service only exists under the https-lan overlay, so it has to be
# named here too — without the -f flags this fails with "no such service".
docker compose -f docker-compose.yml -f docker-compose.https-lan.yml \
cp caddy:/data/caddy/pki/authorities/local/root.crt headroom-ca.crtInstall
root.crt, notintermediate.crt. They sit side by side in that directory and only the root is a trust anchor. An intermediate is presented by the server during the handshake and means nothing until its issuer is already trusted — so installing it appears to succeed and changes nothing, which is exactly what "the certificate won't install" looks like. The two.keyfiles in there are private and never leave the Pi.
3. Trust it on each device (passkeys require a trusted certificate, not just any TLS):
- iPhone / iPad: AirDrop or email
headroom-ca.crtto the device, tap it, then Settings → Profile Downloaded → Install. Finally — easy to miss — enable it under Settings → General → About → Certificate Trust Settings. - Mac: one command does the whole thing, import and trust together —
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain headroom-ca.crt. Double-clicking works too, but it lands in whichever keychain Keychain Access last had selected, and it must be login or System — the iCloud keychain cannot hold certificates and refuses the import withError: -26276, which reads like a bad file rather than a wrong destination. Then open it and set When using this certificate to Always Trust. - Android: Settings → Security → More → Encryption & credentials → Install a certificate → CA certificate.
- Windows: right-click → Install Certificate → Local Machine → place in Trusted Root Certification Authorities.
4. Verify: open https://headroom.local — you should see a padlock and no warning. The Settings page's LAN Discovery card shows the exact URL being advertised. Then add a passkey under Settings → Account and sign in with Face ID.
Reaching it from off the LAN (Teleport, Tailscale, WireGuard, another
subnet): headroom.local will not resolve out there. .local is mDNS, which
is link-local multicast — no server holds that record, so there is no DNS to
point at it. Give Caddy the address you will connect on:
HEADROOM_SITE_ADDRESSES="headroom.local, 10.0.111.4" \
docker compose -f docker-compose.yml -f docker-compose.https-lan.yml up -dCaddy then serves that address and puts it in the certificate (an IP SAN, signed by the same root), so devices that already trust the CA need no reinstall. Without it, connecting by IP fails the handshake before the certificate is even considered — Caddy rejects an SNI matching no site here.
Two caveats. Passkeys still only work on the origin in HEADROOM_ORIGIN —
WebAuthn credentials are bound to an origin, so one registered at
https://headroom.local isn't offered at https://10.0.111.4. That's WebAuthn
working correctly; password login works on both. And if you just want in from a
VPN with no configuration at all, http://<ip>:8000 already works — that's
uvicorn directly, bypassing Caddy. Plain HTTP, so no padlock and no passkeys.
If something's off:
- Works on the LAN, dead over the VPN —
headroom.localis mDNS and mDNS cannot cross a VPN, a tunnel or a routed subnet, so the name doesn't resolve and nothing is wrong with the server. The IP isn't a fallback either: Caddy rejects a TLS connection whose SNI matches no site, and the certificate has no IP SAN. Worse,http://<ip>/gets a 308 redirect tohttps://<ip>/— the one address that can't work — which is what makes this look like an outage. Add the address toHEADROOM_SITE_ADDRESSES(above), or usehttp://<ip>:8000to bypass Caddy entirely. - Name doesn't resolve — Linux clients need
avahi-daemon+libnss-mdns; everything else resolves.localnatively. Check the LAN Discovery card (ordocker compose logs | grep -i mdns) to confirm the app is advertising. - Still a certificate warning — on iOS the profile install alone isn't enough; the Certificate Trust Settings toggle in step 3 must be on.
- The certificate "installs" but nothing changes — you almost certainly
installed
intermediate.crt. Onlyroot.crtis a trust anchor; see the note in step 2. A browser's "export certificate" button hands you the leaf or the intermediate, never the root — a root is self-signed and never sent during a handshake, so it can only come off the Pi. - macOS says
Error: -26276— the import is aimed at the iCloud keychain, which cannot store certificates. Pick login or System in the Keychain Access sidebar, or use thesecurity add-trusted-certcommand in step 3, which never has to guess. - Trusted the root and it is still "Not Secure" — check Settings → Device
→ Trust this device, which now reports the certificate actually
being served. Leaf certificates are issued for 820 days (
./Caddyfile) and Caddy renews them well before that; if renewal stops (a missing stored key will do it) Caddy keeps serving the old one and re-queues the renewal forever, and trusting the issuer cannot help.docker restart headroom-caddymakes it reissue against the same root, so devices that already trust the CA stay trusting. /api/public/ca-certificatereturns 404 while Caddy is plainly serving HTTPS — thecaddy-ca-exportservice isn't running. Recreate the stack; see the note in step 2.- No Face ID prompt — passkeys are bound to the domain they were created
on. One registered at
localhostor a public domain won't be offered atheadroom.local; add a new passkey while on the LAN name. - Port conflict on 80/443 — something else on the host owns them; stop it or fall back to the plain mDNS overlay (password login, no padlock).
Renaming the host (HEADROOM_MDNS_HOSTNAME=hats) carries through everything
— cert, mDNS name, and passkey identity become hats.local on the next
up --build.
Prereqs: git + curl. The setup script installs everything else it needs —
uv, Python, Node, backend and frontend deps — via Homebrew on macOS and
apt/dnf on Linux. It pulls the same versions the Docker image runs
(Python 3.14 via the .python-version pin, Node 26 on a fresh
install), so bare metal doesn't drift from production. An existing Node
22.22+ is accepted as-is — that's react-router 8's engines floor, the
highest any dependency declares, and the Node 20 line went EOL 2026-04-30 —
and the package itself still supports Python 3.12+ if you bring your own
interpreter. npm is upgraded to 12 to match the version the image builds the
SPA with.
git clone https://github.com/59psi/headroom.git && cd headroom
./scripts/setup.sh --no-docker # drop the flag to also install a Docker engine
uv run uvicorn headroom.app:app --host 0.0.0.0 --port 8000That's it — setup builds the SPA, and the backend serves it at http://localhost:8000.
# terminal 1 — backend (port 8000)
uv run uvicorn headroom.app:app --reload
# terminal 2 — frontend dev server (port 5173, proxies /api + /uploads to :8000)
cd frontend && npm run devgit pull
docker compose up --build -d # Docker
# — or —
./scripts/setup.sh --no-docker # bare metal: re-sync deps + rebuild SPA, then restart uvicornSchema changes are handled automatically — on every boot, init_db()
applies inline SQLite migrations (ALTER TABLE for new columns, CREATE TABLE for new tables), so an old database upgrades itself the first time
the new version starts. There's no separate migrate step — but there's no
downgrade path either, so take a backup before major upgrades
(Settings → Upkeep → Backups → ↓ Full Backup, or grab the latest scheduled tarball from
/data/backups/). Your data always survives a rebuild: the database and
photos live in the headroom-data volume, not the image.
The footer shows the running version — compare against the CHANGELOG. Details in OPERATIONS.md §5.
To wipe everything and set up as a brand-new install (fresh database, no
hats/cases/photos, first-run "create owner" screen returns), remove the
headroom-data volume — it holds the database, photos, and the rolling
backups, so download a backup first if you want to keep anything. Use the
same -f flags you deploy with:
docker compose -f docker-compose.yml -f docker-compose.http80.yml down -v
docker compose -f docker-compose.yml -f docker-compose.http80.yml up -d --buildOn the https-lan overlay, -v also clears Caddy's local CA (re-trust the
cert once per device). To keep the cert, or to reset only the database while
keeping photos, see the variants in
OPERATIONS.md §4.
The AI features need an Anthropic API key. The DB-stored key always wins over the environment variable, so you can ship a docker-compose default and let users override it from the UI.
| Source | When | Set via |
|---|---|---|
| Database (preferred) | Set from the Settings page; persists across restarts | UI: Settings → Claude API Key |
| Environment (fallback) | Useful as a default for fresh installs | HEADROOM_ANTHROPIC_API_KEY |
Uploads never depend on Claude. Without a key (or when a Claude call fails),
a basic fallback runs instead and the hat gets analysis_status = "fallback":
- Colors — always available, no key needed. Dominant colors are extracted locally from the background-removed cutout's alpha mask, so only actual hat pixels count.
- Brand — optional, via Google Cloud Vision logo detection. Create an API key at console.cloud.google.com (enable the Cloud Vision API, then Credentials → Create API key) and paste it in Settings → Google Vision Key. Free tier is 1,000 requests/month — plenty.
Model name, price estimate, and design notes stay empty in fallback mode — drop a Claude key in later and hit Reanalyze on any hat to upgrade.
Melin hats get a live median asking price from melinrecap.com's public
marketplace API (it's a Treet marketplace on Sharetribe Flex — we use the
same anonymous API its own frontend uses), matched to your exact product —
model and colorway — in your hat's condition and size, widening to the line
only when nothing that specific is listed; plus a deep link to browse the
comps. Degrades to link-only if the API is unreachable. Prices are
re-checked nightly (HEADROOM_REPRICING_*) and on demand from Settings →
Data → Re-pricing; neither spends a Claude call.
| Variable | Default | Description |
|---|---|---|
HEADROOM_DATABASE_URL |
sqlite+aiosqlite:///./headroom.db |
DB connection string |
HEADROOM_UPLOAD_DIR |
uploads |
Where photos live on disk |
HEADROOM_CORS_ORIGINS |
["http://localhost:5173"] |
Allowed CORS origins (JSON list) |
HEADROOM_ANTHROPIC_API_KEY |
(unset) | Default API key (overridden by DB value) |
HEADROOM_ANTHROPIC_MODEL |
claude-sonnet-5 |
Claude model for vision analysis |
HEADROOM_GOOGLE_VISION_API_KEY |
(unset) | Fallback brand (logo) detection. DB value wins |
HEADROOM_MELIN_CLIENT_ID |
(baked in) | Public Sharetribe client id for live Melin resale stats |
HEADROOM_EBAY_APP_ID / HEADROOM_EBAY_CERT_ID |
(unset) | eBay Browse API comps (Production keyset) |
HEADROOM_SETUP_TOKEN |
(unset) | When set, first-run setup also requires this token (the setup form's Setup token field). Closes the window where whoever reaches the host first can claim the owner account — worth setting on an internet-facing deployment, unnecessary on a LAN. See security posture |
HEADROOM_RP_ID |
localhost |
Passkey relying-party id — must equal the serving domain (HTTPS overlay sets it) |
HEADROOM_ORIGIN |
http://localhost:8000 |
Full origin for passkey verification (HTTPS overlay sets it) |
HEADROOM_REMBG_MODEL |
isnet-general-use |
rembg model (~179MB; keeps hat bills. u2netp is 4.7MB and far faster but trims thin brims) |
HEADROOM_HTTP_TIMEOUT |
30.0 |
Outbound HTTP timeout in seconds (Claude, Google Vision, Melin Recap; eBay has its own fixed timeouts) |
HEADROOM_REMBG_CONCURRENCY |
1 |
Concurrent background-removal inferences — the app's largest allocation, so one at a time in a 1 GB container |
HEADROOM_LOG_LEVEL |
INFO |
Log level when running uvicorn directly |
HEADROOM_BACKUP_ENABLED |
true |
Scheduled backups on/off |
HEADROOM_BACKUP_INTERVAL_HOURS |
24 |
Scheduled backup cadence |
HEADROOM_BACKUP_INCLUDE_CA |
true |
Fold Caddy's local CA — including its private keys — into each backup, under data/caddy-pki/. On by default because the root is installed by hand on every device and nothing can vouch for a replacement, so losing it means visiting them all. Set false if you would rather not have a key that can sign for any host sitting in an archive you upload off-box. See backups |
HEADROOM_BACKUP_KEEP |
5 |
How many rolling local backups to keep (a count, not days). HEADROOM_BACKUP_RETENTION_DAYS is still read, as a count, for older .env files |
HEADROOM_SQLITE_SYNCHRONOUS |
FULL |
SQLite durability — FULL fsyncs every commit, so committed means committed through a power cut. NORMAL/EXTRA/OFF accepted; anything else falls back to FULL |
HEADROOM_MAX_BODY_BYTES |
2097152 |
Cap on non-multipart request bodies (uploads have their own, larger caps) |
HEADROOM_DISK_MIN_FREE_MB |
500 |
Below this free space, /health/ready fails |
HEADROOM_DISK_WARN_PCT |
15 |
Below this share of the volume, log a warning |
HEADROOM_BACKUP_UPLOAD_CMD |
(unset) | Ship each scheduled backup off-box; {path}/{dir}/{name} substituted. Overrides the provider chosen in the Settings UI — host access only, on purpose. See off-site backups + docker-compose.backup-rclone.yml / docker-compose.backup-rsync.yml |
HEADROOM_BACKUP_UPLOAD_TIMEOUT |
600 |
Seconds before the upload command is killed |
HEADROOM_BACKUP_RSYNC_PASSWORD |
(unset) | Password for the Synology / rsync-daemon provider; read from the host, never stored |
HEADROOM_REPRICING_ENABLED |
true |
Nightly re-pricing of melin hats against live Melin Recap asks (no Claude call) |
HEADROOM_REPRICING_INTERVAL_HOURS / _DELAY_SECONDS / _BATCH_LIMIT |
24 / 1.0 / 0 |
Sweep cadence, pause between hats, cap per sweep (0 = all) |
HEADROOM_MDNS_INTERFACE |
(detected LAN IP) | Interface the mDNS responder binds; an IP pins one NIC, all restores all-interfaces |
HEADROOM_IMPORT_WORKER_ENABLED |
true |
Bulk-import background worker |
HEADROOM_ANALYSIS_WORKER_ENABLED |
true |
Photo-analysis background worker (off ⇒ the upload route runs the pipeline inline) |
HEADROOM_ACTIVITY_LOG_RETENTION_DAYS |
90 |
Audit rows kept (pruned daily) |
HEADROOM_MDNS_ENABLED |
true |
Advertise headroom.local on the LAN (Docker: stack docker-compose.mdns.yml, or docker-compose.https-lan.yml for passkey-grade HTTPS) |
HEADROOM_MDNS_HOSTNAME |
headroom |
mDNS host label — resolves as <label>.local |
HEADROOM_MDNS_PORT |
8000 |
Port the mDNS advertisement points at |
HEADROOM_SITE_ADDRESSES |
<HEADROOM_MDNS_HOSTNAME>.local |
LAN HTTPS overlay only. Every name/address Caddy answers on and puts in the certificate, space-separated (Caddy refuses a bare comma). Add the LAN IP to reach it where .local can't resolve — a VPN, a tunnel, another subnet |
HEADROOM_MEM_LIMIT |
1g |
Container memory ceiling (compose mem_limit) |
HEADROOM_BUILD_SHA |
(local git SHA) | Build stamp shown in the footer; scripts/stamp-build.sh writes it into .env |
FORWARDED_ALLOW_IPS |
127.0.0.1 |
Which peers uvicorn trusts for X-Forwarded-*; the Let's Encrypt overlay pins the compose subnet |
Under Docker every HEADROOM_* row above is forwarded into the container by
docker-compose.yml — a .env beside the compose file is interpolation for
Compose, not the container's environment, and a variable Compose does not
forward reaches nothing. The full table, with the operational reasoning, is in
OPERATIONS §2.
The Docker image is multi-arch (amd64 + arm64). On a Pi 4/5 running 64-bit Raspberry Pi OS or Ubuntu Server:
# Build on the Pi (slow first build, fine after)
docker compose up --build -d
# Or build on a beefier machine and push:
docker buildx build --platform linux/arm64,linux/amd64 \
-t your-registry/headroom:latest --push .The default isnet-general-use rembg model is ~179MB and takes appreciably
longer per photo on a Pi 4 than the old 4.7MB u2netp — a trade made once
analysis moved off the request path, since nothing waits on it and u2netp
cut the bills off hats. Build with REMBG_MODEL=u2netp docker compose up -d --build to go back. Photos, database, and backups live in the headroom-data
volume —
see OPERATIONS.md §4 for the backup
and restore procedure.
./scripts/setup.sh # One-shot setup (--help for flags)
uv run uvicorn headroom.app:app --reload # Backend (port 8000)
cd frontend && npm run dev # Frontend (port 5173)
cd frontend && npm run build # Type-check + production SPA build
cd frontend && npm run typecheck # Type-check only
uv run pytest # Backend tests
uv run pytest tests/test_search.py -k color # Single backend test
cd frontend && npm test # Frontend tests
cd frontend && npm run test:watch # Frontend tests, watch modeBackend tests use in-memory SQLite, stub out rembg, authenticate through a
seeded test session, and never call the Anthropic, Google, eBay, or Sharetribe
APIs — every external boundary has a test seam.
Frontend tests run under Vitest + Testing Library in jsdom, with the API
modules mocked at the module boundary. Test files live beside the components
inside src/, so npm run typecheck covers them too. CI runs typecheck →
tests → production build on every PR.
Backend — Python 3.12+ (3.14 in the image), FastAPI, async SQLAlchemy + aiosqlite:
src/headroom/
├── app.py # factory, lifespan, SPA serving, auth gate
├── auth.py # session/token guards + gate middleware
├── config.py # pydantic-settings (HEADROOM_*)
├── database.py # async engine + inline DDL migrations
├── models/ # User, Case, Hat, HatColor, WearLog, Purchase,
│ # ColorwayEntry, ShareLink, ImportJob, …
├── routes/ # health, public, auth, hats, cases, rooms,
│ │ # search, meta, settings, import_jobs, share,
│ │ # share_links, guest, ca_cert
│ └── admin/ # errors, backups, activity, reports, ebay,
│ # catalog, analysis, construction, prices,
│ # repricing, config — prefix + auth once
├── schemas/ # Pydantic I/O, one module per route area
└── services/
├── claude_analysis.py # Claude Vision tool-use → structured result
├── background_removal.py # rembg (ONNX) → transparent PNG
├── color_extraction.py # mask-only colors + LAB distance + palette
├── google_vision.py # fallback brand via logo detection
├── melin_recap.py # live resale median (Sharetribe public API)
├── catalog_service.py # colorway harvest + purchase matching
├── auth_service.py # argon2, sessions, rate limiting
├── passkey_service.py # WebAuthn ceremonies
├── label_service.py # QR label sheets, cases + hats (inline SVG)
├── tag_service.py # What a QR/NFC tag points at, and the host in it
├── hat_analysis_pipeline.py # upload → bg-removal → analyze → price
├── import_service.py # restart-surviving bulk-import worker
└── backup_service.py # scheduled + on-demand tar.gz
Frontend — React 19, Vite, TypeScript, TanStack Query, react-router 8,
Vitest + Testing Library, zero UI framework: hand-rolled synthwave design
system in two CSS files, PWA-installable, native <datalist> autocomplete,
hand-rolled WebAuthn plumbing. No component library, no CSS framework, no
state-management dependency.
frontend/src/
├── pages/ # one per route (SettingsPage is a composition
│ # root over components/settings/)
├── components/
│ ├── layout/ # AppShell, TopNav, BottomNav, Footer
│ ├── common/ # spinner, badges, swatches, modals
│ ├── photos/ # capture + cropper
│ ├── hats/ # filter bar + hat-form fields shared by pages
│ └── settings/ # one card per Settings concern
├── api/ # typed fetch clients
├── types/ # interfaces mirroring the Pydantic schemas
└── test/ # Vitest setup + renderWithProviders
Data model: Rooms → Cases → Hats, plus Rooms → Hats for anything kept out of a case. Cases are type-exclusive (regular or beanie) with per-case capacity. Exactly one room is the default — new cases go there and orphaned cases land there — so it's the only one that can't be deleted; designate another room as default to free it up. Disposed hats keep their history but free their slot.
