Appearance
VPS deployment over Tailscale (no domain, no TLS)
Goal: move the TCG backend from the Windows PC to the VPS, keep using Tailscale to reach it from the phone, and have card images served by our own nginx instead of TCGdex.
This is the Option B setup — quickest path to "VPS only" without buying a domain or configuring Let's Encrypt. The whole tunnel between the phone and the VPS stays inside your Tailscale tailnet, so plain HTTP is acceptable.
For the production / public-domain setup (HTTPS via Caddy), see the companion vps-public-deployment.md guide (to be written when you are ready to move off Tailscale).
What "Option B" means in practice
| Today | After this guide | |
|---|---|---|
| Where the backend runs | Windows PC, Docker Desktop | VPS (Linux) |
| How the phone reaches it | http://100.76.15.57:8000/scan-compat (PC's tailnet IP) | http://<VPS_TAILNET_IP>:8000/scan-compat |
| Where card images come from | TCGdex CDN (assets.tcgdex.net) | Our own nginx on the VPS, fed from data/images/ |
| Phone requirement | Tailscale on | Tailscale on (same constraint) |
| Public exposure | None | None — services bind to the tailnet IP only |
You keep the Tailscale dependency on the phone (already the case today), but the laptop is no longer in the loop.
Prerequisites
- The VPS already has Docker Engine + compose plugin and the
snag-tcg/tcg-collector-backendrepo cloned. - The PR introducing
LOCAL_IMAGE_BASE_URL(#92) is merged intomain. - You have admin access to your Tailscale tailnet (to add the VPS as a node).
- Phone is already on the tailnet (it is, since the current setup works).
Step 1 — Install Tailscale on the VPS
On the VPS:
bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Follow the printed URL on a browser to authenticate the node into your tailnet.
# Tag it (Tailscale admin console → Machines) as e.g. "tcg-vps" for clarity.
# Note the tailnet IP — you'll need it everywhere from here on.
tailscale ip -4
# e.g. 100.124.7.91From now on this guide uses <VPS_TAILNET_IP> as a placeholder for that IP. Replace it with your real value (or export VPS_TAILNET_IP=100.124.7.91 in your shell to copy-paste blocks verbatim).
Sanity check from the laptop (also on the tailnet):
bash
ping -c 3 <VPS_TAILNET_IP>
ssh kael@<VPS_TAILNET_IP> # confirm SSH still works over the tailnetStep 2 — Pull the latest backend on the VPS
SSH into the VPS:
bash
cd ~/snag-tcg/tcg-collector-backend
git checkout main
git pullConfirm the new env vars are visible in .env.example (added by PR #92):
bash
grep -A 2 LOCAL_IMAGE_BASE_URL .env.exampleStep 3 — Configure .env on the VPS
Do not copy the laptop's
.envover as-is. Several values change (LOCAL_IMAGE_BASE_URLmust point at the VPS tailnet IP, not the laptop's;CORS_ORIGINmay differ; etc.), and it's cleaner to treat the VPS as a separate environment with its own secrets. The only.envthat matters on the VPS istcg-collector-backend's —poc-tcg-collectorstays on the laptop,documentationhas no secrets.Exception: if at Step 6 you decide to transfer
data/pg/from the laptop instead of re-running a fresh ingest (~30 min saved), thenPGPASSWORDon the VPS must match the laptop's, otherwise Postgres refuses to mount the volume. By default this guide does a fresh ingest → this constraint doesn't apply, generate fresh secrets.
bash
cp .env.example .env # only if .env doesn't exist yet
# Generate fresh secrets directly on the VPS, and save them in your
# password manager BEFORE editing .env. Once Postgres is initialised
# with PGPASSWORD, changing it requires wiping data/pg/.
openssl rand -hex 32 # → PGPASSWORD
openssl rand -hex 32 # → API_WRITE_TOKEN
nano .envSet at minimum:
bash
# Postgres — pick a strong password before the first `compose up`.
# Once Postgres has initialised data/pg/ with this password, changing it
# requires either ALTER USER inside the running container or wiping the
# data folder. So get this right the first time.
PGUSER=tcg
PGPASSWORD=<long-random-string>
# API
API_WRITE_TOKEN=<long-random-string>
CORS_ORIGIN=* # acceptable on a tailnet; tighten when you move to a domain
# tcg-vision — self-hosted card images.
# IMPORTANT: must be reachable *from the phone*, so use the VPS tailnet IP,
# not localhost (the phone resolves this URL after the scan).
LOCAL_IMAGE_BASE_URL=http://<VPS_TAILNET_IP>:8081/images
LOCAL_IMAGE_EXT=webp
LOCAL_IMAGE_QUALITY=high
# Ingest — first run. Adjust INGEST_SETS to whichever sets you want.
INGEST_SETS=base1,sv01
INGEST_IMAGES=true
INGEST_IMAGE_QUALITY=low,highStep 4 — Bind Docker ports to the tailnet IP only
By default Docker publishes ports on 0.0.0.0, which means anyone on the public internet who finds the VPS can poke at :8000, :8080, :8081, :5432. We want them reachable on the tailnet only.
Edit docker/compose.yml and prefix every ports: mapping with the tailnet IP. Example for tcg-vision:
yaml
ports:
- "<VPS_TAILNET_IP>:8000:8000" # was "${TCG_VISION_PORT:-8000}:8000"Do the same for nginx (<VPS_TAILNET_IP>:8081:80), api (<VPS_TAILNET_IP>:8080:3000), and postgres (127.0.0.1:5432:5432 — Postgres should not even be on the tailnet).
Don't commit this change to the repo — it is VPS-specific. Either use a
docker-compose.override.yml(gitignored on the VPS) or keep it as a local diff. The repo'scompose.ymlshould stay generic.
Cleaner alternative if you don't want to edit compose: keep compose.yml as is and put a ufw rule on the VPS that allows 8000/8080/8081 only from 100.0.0.0/8 (Tailscale CGNAT range):
bash
sudo ufw allow in on tailscale0
sudo ufw deny 8000
sudo ufw deny 8080
sudo ufw deny 8081
sudo ufw deny 5432
sudo ufw allow ssh
sudo ufw enablePick one approach (port binding or firewall) — both works but one is enough.
Step 5 — Bring up the stack
bash
cd ~/snag-tcg/tcg-collector-backend
# Build and start postgres + api + nginx + tcg-vision (skip embeddings — obsolete)
docker compose -f docker/compose.yml up -d --build postgres api nginx tcg-vision
# Watch the logs until everything is healthy (Ctrl+C exits the tail, not the stack)
docker compose -f docker/compose.yml logs -fExpected build time: ~5 to 15 min. The longest is tcg-vision (downloads ~85 MB of DINOv2 weights and builds CPU PyTorch).
Health checks from the laptop (still on the tailnet):
bash
curl http://<VPS_TAILNET_IP>:8000/health # tcg-vision
curl http://<VPS_TAILNET_IP>:8080/sets # api → postgres (will be empty until ingest runs)
curl -I http://<VPS_TAILNET_IP>:8081/ # nginxStep 6 — First ingest
Populates Postgres with sets/cards and data/images/ with the visuals. Long but one-shot:
bash
docker compose -f docker/compose.yml run --rm ingestTail the logs in another terminal if you want to follow the progression. Expect ~30 min depending on INGEST_SETS and TCGdex rate-limiting.
When it finishes, verify the images are actually on disk:
bash
ls data/images/ | head
# A1 A1a A2 ...
ls data/images/A1/001/
# high.webp low.webpAnd that nginx serves them over the tailnet:
bash
curl -I http://<VPS_TAILNET_IP>:8081/images/A1/001/high.webp
# HTTP/1.1 200 OK
# Content-Type: image/webpStep 7 — Update the mobile app
On your laptop, in the poc-tcg-collector repo:
bash
cd ~/projects/poc-tcg-collector # or wherever it is on your machine
git checkout main && git pull
git checkout -b chore/repoint-scan-url-to-vpsEdit src/scan/backendConfig.ts:45:
ts
// Before:
export const BACKEND_SCAN_URL = 'http://100.76.15.57:8000/scan-compat';
// After (replace with your VPS tailnet IP):
export const BACKEND_SCAN_URL = 'http://<VPS_TAILNET_IP>:8000/scan-compat';Commit, push, open a PR (per repo rules, no direct push to main):
bash
git add src/scan/backendConfig.ts
git commit -m "chore(scan): repoint BACKEND_SCAN_URL to VPS tailnet IP"
git push -u origin chore/repoint-scan-url-to-vps
gh pr create --base mainAfter merge, rebuild the APK and reinstall on the phone:
bash
cd android
./gradlew assembleRelease
# Install android/app/build/outputs/apk/release/app-release.apk on the phoneStep 8 — End-to-end test from the phone
Phone must be on Tailscale (it already is). Open the app, scan a card.
Expected:
- Scan returns within ~1-2 s.
- A card visual appears (served by your VPS nginx, not TCGdex).
- In
tcg-visionlogs on the VPS you should see a realPOST /scan-compatrequest, and immediately after, an nginx log line likeGET /images/A1/001/high.webp 200.
If the image stays blank, check:
LOCAL_IMAGE_BASE_URLactually uses the VPS tailnet IP (notlocalhostor127.0.0.1).- The card the FAISS matched belongs to a set you ingested. If not, the URL 404s — re-run ingest with the missing set in
INGEST_SETS. tailscale statuson the phone — sometimes it goes to sleep on Android. Tap the Tailscale notification to confirm the tunnel is up.
Step 9 — Decommission the laptop stack (optional but recommended)
Once you've confirmed the phone is talking to the VPS:
powershell
# On the laptop
cd C:\Users\Kael\Documents\Claude\Projects\SKN\tcg-collector-backend
docker compose -f docker/compose.yml down
# Containers stopped & removed; data/pg and data/images stay on disk as a backup.The Docker images themselves can stay around (they cost nothing while stopped); prune them later with docker image prune -a once you're confident you won't roll back.
Recurring maintenance
Future updates on the VPS are now a one-liner:
bash
cd ~/snag-tcg/tcg-collector-backend
git pull
docker compose -f docker/compose.yml up -d --buildFor a fresh ingest (new sets): docker compose -f docker/compose.yml run --rm ingest.
When to graduate to Option A (public domain + HTTPS)
Switch when any of these become true:
- You want non-Tailscale users to scan (real beta testers, friends, the public).
- The phone's Tailscale connection is becoming a friction point (battery, dropouts on bad mobile networks).
- You start handling user data that you'd be uncomfortable shipping over plain HTTP, even on a tailnet.
The migration is small: install Caddy on the VPS, point a (sub)domain at the VPS public IP, swap LOCAL_IMAGE_BASE_URL and BACKEND_SCAN_URL from http://<VPS_TAILNET_IP>:... to https://scan.your-domain.com/.... The compose stack itself doesn't change.