Appearance
Session 2026-05-05 — VPS Option B (Tailscale) deployment end-to-end
Full session log: what we did, what broke, what we learned. Read top-down for the whole story, or jump to a specific decision.
Starting context
Hetzner Ubuntu Noble VPS (ubuntu-8gb-nbg1-1, public IP 188.245.104.87). Already bootstrapped (Docker, Go, gh installed; snag-tcg/* repos cloned). fail2ban applied in a previous session. SSH key-only hardening deferred.
Session goal: execute vps-tailscale-deployment.md Steps 1-6 (VPS side), then extend with full debugging when the phone failed to load scanned card images.
Phase 1 — Tailscale deployment (Steps 1-6 of the guide)
Step 1 — Install Tailscale + auth
curl -fsSL https://tailscale.com/install.sh -o /tmp/tailscale-install.sh && bash /tmp/tailscale-install.sh(preferred over direct pipe to avoid RTK + sudo gotchas)sudo tailscale uplaunched in background, auth URL read from stdout, validated in laptop browser- VPS tailnet IP:
100.108.38.24
Step 2 — Pull the backend
Already done at the start of the session: checkout main + pull on all 4 repos (routines, tcg-collector-backend, poc-tcg-collector, documentation). The documentation repo didn't have the Tailscale guides locally — they were on remote branches (origin/docs/vps-tailscale-deployment, origin/docs/vps-public-deployment). Pulling main brought them in (already merged).
Step 3 — Configure .env
Generated 2 secrets via openssl rand -hex 32, displayed in plaintext ONCE for password manager save, then written to .env after user GO:
| Var | Value |
|---|---|
PGPASSWORD | hex 64 secret |
API_WRITE_TOKEN | hex 64 secret |
LOCAL_IMAGE_BASE_URL | http://100.108.38.24:8081/images |
chmod 600 .env, gitignored by .gitignore:15.
Step 4 — ufw
Chosen (vs port binding) since aligned with future Option A. Rules:
ALLOW IN on tailscale0 # everything via tailnet
DENY 8000, 8080, 8081, 5432 # defense in depth
ALLOW 22/tcp # SSH allowed before enable, otherwise session cut
ufw --force enable--force essential to avoid the "are you sure?" prompt that hangs without TTY.
Real talk: Docker bypasses ufw via DOCKER-USER/PREROUTING chains. So DENY 8000/8080/8081 doesn't really block Docker-published ports — the Hetzner Cloud Firewall upstream does the actual public-surface filtering. ufw still useful for 5432 (Postgres direct) and readability.
Step 5 — Docker stack — first partial attempt
bash
sg docker -c "docker compose -f docker/compose.yml up -d --build postgres api nginx tcg-vision"sg docker -c because the SSH/tmux/Claude session inherited Unix groups frozen at fork() from before usermod -aG docker kael. tmux attach does not respawn any process → no point reconnecting for this session. Detailed in vps-troubleshooting §1.
Result: 3 services up out of 4. nginx failed to start:
mkdirat .../usr/share/nginx/html/images: read-only file systemBug #1 — missing nginx mountpoint
docker/compose.yml stacks 2 bind mounts:
yaml
volumes:
- ../app:/usr/share/nginx/html:ro # mount #1
- ../data/images:/usr/share/nginx/html/images:ro # mount #2 — INSIDE mount #1Mount #2 needs an images/ mountpoint inside mount #1. The source app/images/ doesn't exist (git ignores empty directories). On strict Linux/runc, creating the mountpoint inside a :ro overlay is refused. Docker Desktop on Windows was lenient → never showed up before.
Fix applied: mkdir app/images on VPS. Upstream PR opened and merged: tcg-collector-backend#93 — adds app/images/.gitkeep so the mountpoint is checked out on clone.
After mkdir: recreate nginx → 4/4 services up.
Step 6 — First ingest
bash
sg docker -c "docker compose -f docker/compose.yml run --rm ingest"With INGEST_SETS=base1,sv01: 8.3 seconds (vs 30 min announced by the guide). 360 cards, 720 images (low + high), 27.7 MB on disk, 0 fails.
Phase 2 — SSH key-only hardening
Deferred from previous session, picked up on user request. Procedure:
bash
echo "PasswordAuthentication no" | sudo tee /etc/ssh/sshd_config.d/99-disable-password.conf
sudo sshd -t # validation
sudo systemctl reload sshUbuntu Noble note: ssh.service is disabled but ssh.socket is enabled → socket activation. systemctl reload ssh works regardless.
Verification:
permitrootlogin without-password
pubkeyauthentication yes
passwordauthentication no ← applied
kbdinteractiveauthentication noConfirmed laptop-side: ssh kael@vps works (key), ssh -o PreferredAuthentications=password ... rejected.
Phase 3 — Documentation (vps-troubleshooting.md)
After several traps hit during the session, bilingual doc created to capture recurring patterns:
- Docker
permission deniedpost-usermod -aG docker(Unix groups frozen at fork, tmux attach doesn't help) apt upgradehanging on whiptail (DEBIAN_FRONTEND=noninteractive+NEEDRESTART_MODE=a)gpg --dearmor+ curl pipe → "invalid radix64" (save first)gpg --dearmor+ no-TTY → "cannot open /dev/tty" (--batch --yes)- RTK + sudo → "rtk: command not found" (
/usr/bin/sudo /usr/bin/...) gh auth logindevice code (Claude must read stdout, not refuse)
PR merged: documentation#7.
Phase 4 — Step 7 (laptop) — repoint the mobile app
User-side, on laptop: poc-tcg-collector/src/scan/backendConfig.ts:45 modified to point at 100.108.38.24:8000 instead of 100.76.15.57:8000 (laptop). PR #107 merged. APK rebuilt, installed on the OnePlus Nord 4.
First test from phone: Network request failed.
Phase 5 — Debug "Network request failed"
Bug #2 — network_security_config.xml hardcoded whitelist
User inspected poc-tcg-collector/android/app/src/main/res/xml/network_security_config.xml and found a cleartext IP whitelist hardcoded on the old laptop IP:
xml
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">100.76.15.57</domain> ← laptop only
</domain-config>Android API 28+ blocks HTTP cleartext by default, except for hosts whitelisted here. The new VPS IP 100.108.38.24 wasn't there → <Image> (Fresco) refuses the fetch.
Fix: add 100.108.38.24 to the whitelist. PR #126 merged. APK rebuilt + reinstalled.
Phase 6 — Debug "Card details OK but image won't load"
The scan POST goes through (visible in tcg-vision logs), card details display in the app, but the image stays blank and no GET /images/... shows up in nginx logs.
Bug #3 — Docker Compose doesn't load .env
Inspected effective env in the tcg-vision container:
LOCAL_IMAGE_BASE_URL=Empty. But the repo-root .env does have LOCAL_IMAGE_BASE_URL=http://100.108.38.24:8081/images.
Cause: Docker Compose v2 looks for .env in the project directory, which by default is the directory of the compose.yml (so docker/). Not in cwd. docker/.env doesn't exist → no var loaded → ${LOCAL_IMAGE_BASE_URL:-} resolves to empty string. Same for ${PGPASSWORD:-tcg} falling back to default tcg.
Consequences:
- Postgres was running with default password
tcg(while exposed on the tailnet — security issue) - tcg-vision was NOT rewriting image URLs (returning the default TCGdex CDN URL, not our nginx)
Fix:
bash
# 1. Align Postgres live with the .env secret
PGPASSWORD_REAL=$(grep ^PGPASSWORD= .env | cut -d= -f2)
sg docker -c "docker exec tcg-collector-postgres-1 psql -U tcg -c \"ALTER USER tcg WITH PASSWORD '$PGPASSWORD_REAL';\""
# 2. Always pass --env-file explicitly
sg docker -c "docker compose --env-file .env -f docker/compose.yml up -d --force-recreate"Post-recreate verification:
LOCAL_IMAGE_BASE_URL=http://100.108.38.24:8081/images ✅
POSTGRES_PASSWORD=492c…b5fe ✅Takeaway: always --env-file .env when running docker compose from the repo root. To document upstream in tcg-collector-backend/CLAUDE.md, or fix by removing dangerous defaults from compose (${PGPASSWORD:?missing} instead of ${PGPASSWORD:-tcg}), or by moving .env to docker/.env.
Bug #4 — FAISS vs local disk coverage
After the env fix, the app does receive URLs rewritten to nginx. But still no image loading.
Laptop test with curl -I http://100.108.38.24:8081/images/base1/2/high.webp → 200 OK (image available). Test in phone Chrome at the same URL → image displayed. So network OK + server OK + cleartext config OK.
So the app remains. Inspecting poc-tcg-collector/src/scan/ScanResultCard.tsx:21:
tsx
{details.image ? (
<Image source={{ uri: details.image }} ... />
) : null}If details.image is null/empty → <Image> never renders → 0 GETs, consistent with empty nginx logs.
Exhaustive test on VPS:
bash
curl -X POST -F "photo=@data/images/base1/25/high.webp" http://100.108.38.24:8000/scan-compat
# → match: xy12-29 Lamantine
# → image: http://100.108.38.24:8081/images/xy12/29/high.webpI uploaded a base1/25 card (Dewgong) and FAISS returned a different Lamantine, from set xy12. xy12 is not on disk → the returned image URL 404s.
| Source | Cards |
|---|---|
FAISS index in tcg-vision | 30,370 |
Locally ingested sets (base1 + sv01) | 360 |
| Coverage | 1.1% |
→ 98.9% of real-photo scans (noise, perspective, glare) will land on a card we don't have locally. URL → 404. And Android <Image> silently drops it (either before the request, or after receiving the 404).
Chosen fix (Option A) — re-ingest all sets ✅
bash
sed -i 's/^INGEST_SETS=.*/INGEST_SETS=all/' .env
sg docker -c "docker compose --env-file .env -f docker/compose.yml run --rm ingest"Actual result: 7 min 36 s (not the announced hours), 208 sets, 23,159 cards, 43,078 images, 1.84 GB on disk. FAISS coverage went from 1.1 % to 76 %.
Long-term fix (Option C) — TCGdex fallback on tcg-vision side ✅
Patched and merged: tcg-collector-backend#94. _build_card_image_url now checks os.path.exists() before rewriting the URL; missing file → TCGdex CDN fallback instead of a guaranteed 404. compose.yml now mounts data/images:ro for tcg-vision, and the new LOCAL_IMAGE_DATA_DIR env var (defaults to /app/data/images) enables the check. Covers the residual ~24 % of cards not on disk (Pokémon JP, One Piece, Lorcana, etc.).
Final state
What works today
- VPS up, secured (fail2ban + ufw + Hetzner FW + SSH key-only)
- Tailscale node
100.108.38.24reachable from the phone - Docker stack 4/4 services healthy (postgres, api, nginx, tcg-vision)
.envproperly loaded via--env-file- App identifies cards (POST scan-compat → 200, details displayed)
What's left to finish
Everything identified during the session was closed before it ended:
Ingest all sets✅ done (7 min, 23k cards, 76% FAISS coverage)Upstream PR✅ merged (backend#94)fix/tcg-vision-image-fallbackUpstream✅ enforced via backend#95 (compose hard-fails when--env-filenotePGPASSWORDis missing +CLAUDE.mdupdated + header in.env.example)- Step 9 (optional, laptop side):
docker compose downto free up laptop Docker resources — still pending user choice
Phase 7 — Late-session security hardening
After the initial doc, security fix to prevent trap #3 (.env silently not loaded → Postgres on default password) from recurring:
${PGPASSWORD:-tcg}× 4 services →${PGPASSWORD:?missing PGPASSWORD — pass --env-file .env or export it}. Compose hard-errors out ifPGPASSWORDisn't loaded, instead of booting with a known default.CLAUDE.mddocuments--env-file .envin everydocker composecommand.- Header in
.env.examplereminds about the requirement.
PR merged: tcg-collector-backend#95. Verified both sides:
- With
--env-file .env→ strong password resolves, everything works - Without → explicit fail at compose config-time, message points to the fix
Traps hit (summary)
tmux attachdoesn't reload Unix groups (groups frozen at child process fork)app/images/mountpoint missing on strict Linux — fixed by backend#93- Android
network_security_config.xmlcleartext whitelist by IP — fixed by poc#126 - Docker Compose v2 looks for
.envin project dir =compose.ymldirectory, not cwd — fixed by backend#95 (fail-fast instead of silent fallback) - tcg-vision blanket-rewrites to nginx without checking if file exists → silent 404 on Android Fresco — fixed by backend#94
Security measures applied
- Postgres
PGPASSWORDrandom hex 64 - API
API_WRITE_TOKENrandom hex 64 - SSH key-only, password disabled
- ufw active, default deny incoming
.envchmod 600, gitignored
Internal references
- VPS deployment over Tailscale — guide we executed
- Public VPS deployment (Caddy + HTTPS) — next step when leaving Tailscale
- VPS / deployment troubleshooting — recurring pitfalls
~/POST_DEPLOYMENT.md(on the VPS) — post-deployment ops runbook