Skip to content

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 ​

TodayAfter this guide
Where the backend runsWindows PC, Docker DesktopVPS (Linux)
How the phone reaches ithttp://100.76.15.57:8000/scan-compat (PC's tailnet IP)http://<VPS_TAILNET_IP>:8000/scan-compat
Where card images come fromTCGdex CDN (assets.tcgdex.net)Our own nginx on the VPS, fed from data/images/
Phone requirementTailscale onTailscale on (same constraint)
Public exposureNoneNone — 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-backend repo cloned.
  • The PR introducing LOCAL_IMAGE_BASE_URL (#92) is merged into main.
  • 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.91

From 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 tailnet

Step 2 — Pull the latest backend on the VPS ​

SSH into the VPS:

bash
cd ~/snag-tcg/tcg-collector-backend
git checkout main
git pull

Confirm the new env vars are visible in .env.example (added by PR #92):

bash
grep -A 2 LOCAL_IMAGE_BASE_URL .env.example

Step 3 — Configure .env on the VPS ​

Do not copy the laptop's .env over as-is. Several values change (LOCAL_IMAGE_BASE_URL must point at the VPS tailnet IP, not the laptop's; CORS_ORIGIN may differ; etc.), and it's cleaner to treat the VPS as a separate environment with its own secrets. The only .env that matters on the VPS is tcg-collector-backend's — poc-tcg-collector stays on the laptop, documentation has 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), then PGPASSWORD on 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 .env

Set 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,high

Step 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's compose.yml should 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 enable

Pick 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 -f

Expected 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/        # nginx

Step 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 ingest

Tail 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.webp

And 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/webp

Step 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-vps

Edit 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 main

After 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 phone

Step 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-vision logs on the VPS you should see a real POST /scan-compat request, and immediately after, an nginx log line like GET /images/A1/001/high.webp 200.

If the image stays blank, check:

  • LOCAL_IMAGE_BASE_URL actually uses the VPS tailnet IP (not localhost or 127.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 status on the phone — sometimes it goes to sleep on Android. Tap the Tailscale notification to confirm the tunnel is up.

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 --build

For 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.