VPS Setup Checklist
Use this checklist to rebuild the current 3plates VPS database and API validation path from a clean server.
Server prerequisites
- A Linux VPS with SSH access.
- Git.
- Node.js 24.
- pnpm 9.15.4.
- Docker with the Compose plugin, or
docker-compose. curl.
VPS operating constraints
- Treat the VPS as slow and capacity-constrained.
- Run only one remote command or SSH session at a time against the VPS.
- Allow at least 5 minutes for a remote task to finish before treating it as timed out.
- For SSH or
rsyncconnection timeouts, wait 30 seconds and retry the same operation. Make no more than 2 retry attempts after the original timeout. - Before killing a remote task, first verify with
ps auxthat the target process is not using CPU. - Do not start a replacement deployment, restart, or validation command while a prior remote task is still running.
Repository setup
- Clone the repository onto the VPS.
- Check out the branch or commit you intend to run.
- Run
pnpm install --frozen-lockfile. - Copy
.env.exampleto.env. - Fill in
.envwith VPS values.
Required .env values for the database and API validation path:
POSTGRES_DBPOSTGRES_USERPOSTGRES_PASSWORDPOSTGRES_PORTDATABASE_URLAPI_PORTAPI_HOSTAUTH_SECRETAUTH_BASE_URLAUTH_GOOGLE_CLIENT_IDAUTH_GOOGLE_CLIENT_SECRETAUTH_APPLE_CLIENT_IDAUTH_APPLE_TEAM_IDAUTH_APPLE_KEY_IDAUTH_APPLE_PRIVATE_KEYorAUTH_APPLE_PRIVATE_KEY_PATHAUTH_SESSION_TTL_DAYSADMIN_API_KEY
Keep .env out of git.
Database setup
- Confirm
docker-compose.ymlpublishes Postgres only on127.0.0.1:${POSTGRES_PORT}. - Confirm
DATABASE_URLuseslocalhostor127.0.0.1with the same port. - Start Postgres and apply migrations:
pnpm db:setup
- Confirm the container is healthy:
docker compose ps postgres || docker-compose ps postgres
- If setup fails, inspect database logs:
pnpm db:logs
API validation
Run the API in one shell:
pnpm dev:api
Run the database smoke test in another shell:
pnpm db:test
The smoke test must pass these checks:
- Docker is reachable.
- Postgres starts and accepts connections.
- Drizzle migrations apply cleanly.
- Required tables exist.
- The API health endpoint responds.
- A real bearer session authenticates
/users/me. - Progress updates persist and read back through the API.
- Preferences updates persist and read back through the API.
- Notification device registration writes through the API.
- Unauthenticated state requests return
401. - Sign-out revokes the bearer session.
Network and security checks
- Confirm Postgres is not listening publicly:
ss -tulpn | grep 5432
The expected bind address is 127.0.0.1:${POSTGRES_PORT}, not 0.0.0.0:${POSTGRES_PORT}.
- Confirm the API binds to loopback when it is running behind the VPS web proxy:
API_HOST=127.0.0.1
- Confirm the API health route from the VPS:
curl -fsS "http://127.0.0.1:${API_PORT}/health"
- Confirm the public firewall does not expose Postgres or the API port.
- Confirm SSH access still works before ending the maintenance session.
Public web proxy
The VPS uses Caddy as the public HTTP/S front door. Keep Caddy on public
80 and 443, and keep backend services on loopback-only ports.
Current production routing:
forum.christitus.com-> Discourse on127.0.0.1:8443.3spinningplates.com-> static Expo web export in/var/www/3plates.api.3spinningplates.com-> Fastify API on127.0.0.1:3000.
Current backend bind rules:
- Discourse exposes
127.0.0.1:8080:80and127.0.0.1:8443:443. - The 3plates API uses
API_HOST=127.0.0.1. - Postgres remains on
127.0.0.1:${POSTGRES_PORT}.
Validate the public routes after any proxy or deployment change:
curl -I https://forum.christitus.com
curl -I https://3spinningplates.com
curl -fsS https://api.3spinningplates.com/health
Validate the expected listener layout on the VPS:
ss -tulpn | grep -E ':(80|443|3000|8080|8443)\b'
Web deployment
Deploy the static Expo web build with:
pnpm deploy:web
The deploy command builds a clean production web export with
EXPO_PUBLIC_API_URL=https://api.3spinningplates.com, creates a timestamped
backup of /var/www/3plates, syncs the export to the VPS, and validates the
public Progress and Workouts routes. If an SSH or rsync connection times out,
it waits 30 seconds and retries the same operation up to 2 times before failing.
Ready criteria
pnpm typecheckpasses.pnpm testpasses.pnpm db:testpasses with the API running.- Postgres is bound to loopback only.
- The repository docs publish from
docs/onmain.