Appearance
Public VPS deployment (domain + HTTPS)
Goal: take the TCG backend off the tailnet and make it publicly reachable, with a real domain name and HTTPS, without changing the application stack itself.
This is the Option A setup — the production target. The companion vps-tailscale-deployment.md guide covers the intermediate step (Tailscale, no domain, plain HTTP). This guide describes the steady state where any phone, with no Tailscale, can scan a card.
What changes vs. Option B
| Option B (Tailscale) | Option A (public) | |
|---|---|---|
| Backend URL on the phone | http://<VPS_TAILNET_IP>:8000/scan-compat | https://tcg.your-domain.com/scan-compat |
| Image URL | http://<VPS_TAILNET_IP>:8081/images/... | https://tcg.your-domain.com/images/... |
| TLS | None (Tailscale tunnel encrypts) | Automatic Let's Encrypt via Caddy |
| Phone requirement | Tailscale on | None |
| Public surface | None (tailnet binding only) | 80 + 443 open to the world |
| Postgres | Internal only | Internal only (never exposed) |
The application stack does not change — postgres, api, nginx, tcg-vision keep running as is. We just put Caddy in front as the public reverse proxy with automatic TLS, and we tighten network bindings and the firewall.
Target architecture
Internet (phone, browser)
│
HTTPS │ 443
▼
┌──────────────────────┐
│ Caddy │ container, exposed on 0.0.0.0:80,443
│ (TLS + reverse │ - Let's Encrypt (HTTP-01)
│ proxy, single │ - HSTS, gzip, rate-limit
│ public entrypoint) │
└─┬───────────┬────────┘
│ │
/scan-compat /images/**, /api/**
│ │
▼ ▼
┌──────────┐ ┌─────────┐ ┌──────────┐
│ tcg- │ │ nginx │───►│ api │
│ vision │ │ (serves │ │ (Fastify)│
│ :8000 │ │ images, │ │ :3000 │
│(internal)│ │ proxies │ │(internal)│
└──────────┘ │ /api) │ └─────┬────┘
│ :80 │ │
│(internal) ▼
└─────────┘ ┌──────────┐
│ postgres │
│ :5432 │
│(internal)│
└──────────┘Key points:
- Caddy is the only service exposed to the public internet (80, 443).
nginx,api,tcg-vision,postgresonly bind on the internal Docker network — invisible from outside.- The image service stays the internal
nginx: don't change its config, it's the one that knows the right cache headers (Cache-Control: public, max-age=31536000, immutable) for the webp visuals. Caddy simply proxies/images/**tonginx:80. - TLS terminates at Caddy. Everything behind it stays plain HTTP on the Docker network — that's fine, the Docker network is only reachable from the host.
Prerequisites
- The Option B stack already runs on the VPS (postgres + api + nginx + tcg-vision via
docker compose). - You own a domain name and have access to its DNS zone.
- Ports 80 and 443 are open in the provider's firewall (usually OK by default — confirm in the VPS control panel).
- A valid email for Let's Encrypt notifications (cert expiry, etc.).
Step 1 — Point DNS at the VPS
In your registrar's DNS console, add:
Type Name Value TTL
A tcg <VPS_PUBLIC_IP> 300
AAAA tcg <VPS_IPv6 if any> 300Verify propagation (from anywhere):
bash
dig +short tcg.your-domain.com
# must return the VPS public IPUntil dig returns the right IP, do NOT proceed — Let's Encrypt will fail the HTTP-01 challenge.
Step 2 — Tighten network bindings in compose.yml
On the VPS, in ~/snag-tcg/tcg-collector-backend/docker/compose.yml (or via a gitignored docker-compose.override.yml), change ports: so that no backend service is published on the host. They stay on the Docker network only.
Before (Option B):
yaml
api:
ports:
- "<VPS_TAILNET_IP>:8080:3000"
nginx:
ports:
- "<VPS_TAILNET_IP>:8081:80"
tcg-vision:
ports:
- "<VPS_TAILNET_IP>:8000:8000"After (Option A) — drop the ports: blocks for api, nginx, tcg-vision. Postgres too (if it was bound on 127.0.0.1:5432). The Docker network is enough for Caddy to reach them by service name.
If
compose.ymlis shared across environments, prefer a VPS-localdocker-compose.override.ymlthat re-declares those services withoutports:. Keep the repo generic.
Step 3 — Add Caddy to the compose
Create docker/Caddyfile at the backend repo root:
nginx
tcg.your-domain.com {
encode gzip zstd
# Reasonable security headers for an API
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# Card scanner
handle /scan-compat* {
reverse_proxy tcg-vision:8000
}
# Card images (served by the internal nginx, which sets the right cache headers)
handle /images/* {
reverse_proxy nginx:80
}
# Fastify REST API
handle /api/* {
reverse_proxy nginx:80 # nginx already proxies /api → api:3000
}
# Catch-all: clean 404
handle {
respond "Not found" 404
}
}Add the Caddy service to compose.yml (or override):
yaml
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # ACME certs and keys — DO NOT LOSE
- caddy_config:/config
depends_on:
- nginx
- tcg-vision
networks:
- default
volumes:
caddy_data:
caddy_config:The caddy_data volume stores the certificates issued by Let's Encrypt. Back it up (a weekly rsync to cold storage is fine) — otherwise, if the VPS is wiped, you re-issue from scratch, and Let's Encrypt rate-limits identical certs to 5 per week.
Step 4 — Update .env
bash
# Phone now hits the public URL. Images must point to the same domain
# to avoid mixed-content and CORS issues.
LOCAL_IMAGE_BASE_URL=https://tcg.your-domain.com/images
# Tighten CORS — no more wildcard
CORS_ORIGIN=https://tcg.your-domain.com
# If the mobile app makes requests from a different scheme (capacitor://,
# file://) add them as a comma-separated list.Step 5 — Firewall: drop everything except 22/80/443
If you had ufw rules from Option B allowing 8000/8080/8081 on tailscale0, remove them — those ports aren't published on the host any more, but it's worth being clean:
bash
sudo ufw status numbered
sudo ufw delete <numbers of the 8000/8080/8081 rules>
# Target state
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp # HTTP/3
sudo ufw enable
sudo ufw statusTailscale can stay installed on the VPS for admin use (SSH over the tailnet is safer than public SSH), but it's no longer in the application traffic path.
Step 6 — Bring it up
bash
cd ~/snag-tcg/tcg-collector-backend
docker compose -f docker/compose.yml up -d --build
# Watch Caddy issue the Let's Encrypt cert
docker compose -f docker/compose.yml logs -f caddyOn first boot, Caddy runs an HTTP-01 challenge on :80. You should see a line like certificate obtained successfully. If it fails:
- DNS hasn't propagated yet (re-
dig). - Port 80 blocked by the provider firewall (check the VPS control panel, not just ufw).
- Another app (residual apache?) is listening on 80 —
sudo ss -tlnp | grep :80.
Health checks from anywhere:
bash
curl -I https://tcg.your-domain.com/scan-compat # should respond (GET not supported → 405 or 200, no TLS error)
curl -I https://tcg.your-domain.com/images/A1/001/high.webp
# HTTP/2 200
# content-type: image/webp
# cache-control: public, max-age=31536000, immutable
# Quick sanity:
curl -sI https://tcg.your-domain.com | head -1
# HTTP/2 200For a fuller audit, SSL Labs should give A or A+ with Caddy's defaults.
Step 7 — Repoint the mobile app
In poc-tcg-collector:
ts
// src/scan/backendConfig.ts
export const BACKEND_SCAN_URL = 'https://tcg.your-domain.com/scan-compat';Commit, PR, merge, rebuild APK, reinstall. From here on, the phone no longer needs Tailscale to scan.
Step 8 — Decommission Tailscale from the application path
Tailscale can stay around for admin, but the mobile app no longer depends on it. If you want to remove it entirely:
bash
sudo tailscale down
sudo tailscale logout
# or, more aggressively:
sudo apt remove --purge tailscaleAnd remove the node from the Tailscale admin console.
Maintenance
Let's Encrypt certs auto-renew (Caddy handles it ~30 days before expiry). Nothing to do as long as:
- DNS keeps resolving to the VPS.
- Port 80 stays open (Caddy needs it for HTTP-01 renewals).
- The
caddy_datavolume is not lost.
Application updates: same as Option B.
bash
cd ~/snag-tcg/tcg-collector-backend
git pull
docker compose -f docker/compose.yml up -d --buildRolling back to Option B
If something breaks in prod and you want to fall back to the tailnet quickly:
- Restore the
<VPS_TAILNET_IP>:...ports:in the compose (or re-enable the Option B override). docker compose up -d— Caddy can stay stopped or run alongside.- Repoint the mobile app to the tailnet IP (revert the Step 7 commit).
- Ask users to re-enable Tailscale.
As long as caddy_data is preserved, you can flip in either direction without re-issuing certs.
When to take it further
This config is enough for 1 to 1000 users doing light reads / scans. Beyond that, consider:
- CDN in front of Caddy (Cloudflare, Bunny) if image bandwidth becomes the bottleneck —
data/images/is immutable, perfect for an edge cache. - Managed Postgres (Neon, Supabase, RDS) if you don't want to run backups yourself.
- API authentication if you open the backend to third-party clients (today
API_WRITE_TOKENprotects writes, but/scan-compatis anonymous — fine for a single-client mobile app, not for a wider public). - Observability: structured logs, metrics (Caddy exposes Prometheus natively via
metrics).
None of these additions break the topology above — they stack on top.