Skip to content

Self-hosting the APIs on the VPS

Moving the APIs off Vercel onto our own droplet, so any team member can ship without going through a personal Vercel account — and so client-api gets a real development environment.

Status — live as of 2026-08-06

client-api serves api.foundationaldatalabs.com from the droplet; Relive's traffic moved off Vercel. internal-api's data endpoints serve internal.foundationaldatalabs.com. Push-to-deploy is wired through the Coolify GitHub App on PDGTeam. See Infrastructure Diagrams for the current topology.

Why the split ends up where it does

internal-api's /mail endpoint stays on Vercel, and that is deliberate, not laziness. DigitalOcean blocks outbound TCP 25/465/587 droplet-wide and refused to lift it (ticket 2026-07-28). Migadu's SMTP is 465. The outreach daemon therefore builds each MIME message locally and POSTs it to internal-api's POST /mail?resource=relay, which submits it from Vercel, where there is no port block. Move that endpoint onto this droplet and every outreach send stops working.

mail.ts is the only file that needs SMTP egress — it is also the only user of MAIL_ACCOUNTS — so everything else in internal-api moved to the droplet. Same codebase, two deployments, different consumers: the droplet copy simply never receives MAIL_ACCOUNTS. client-api has no such constraint at all.

The request path

On Vercel, vercel.json's rewrites are the router: /v1/properties/:id is rewritten to /api/v1?resource=properties&id=:id and the platform invokes api/v1.ts. Self-hosting has no platform doing that, so client-api/server.ts does it — and only that.

The three handlers under client-api/api/ are not modified. They stay the reviewed, tested code that runs in production; the shim adapts a Node/Express request into the VercelRequest/VercelResponse shape they already expect. The migration changes where the code runs, not what it does.

That leaves one hazard: the route table is now expressed twice, in vercel.json and in server.ts. client-api/test/server.test.ts pins them to each other — every rewrite must have a matching route and vice versa, with the same handler and the same injected query parameters. Add an endpoint to one and the suite fails until you add it to the other.

cd client-api && npm run check && npm test    # 142 tests
npm run build && npm start                    # dist/main.js, PORT=3000

The development database

A Postgres container on the droplet — not a database on the managed cluster.

The cluster allows 25 connections total, 3 of them superuser-reserved, and pgbouncer's pools are sized per (database, user). A dev database there would spend the same scarce budget production and the outreach jobs draw from, so a developer running a load test could starve the client API. Here, dev spends zero cluster connections.

Container fd-dev-postgres, postgres:18-alpine (matches the cluster's 18.4)
Volume fd-dev-pgdata — survives restarts, image upgrades and droplet reboots
Network coolify, reachable from other containers as fd-dev-postgres:5432
Exposure 127.0.0.1:25433 only — never the public internet
Credentials deploy/dev-db/secrets/ on the droplet, mode 600, generated in place

It is deliberately not backed up. It is rebuilt from seed in seconds, and treating a dev database as precious is how it quietly becomes a second system of record.

# Provision (idempotent — keeps the existing volume, data and passwords)
uv run python scripts/dump_schema_ddl.py > /tmp/dev-schema.sql
./deploy/dev-db/provision.sh --schema-file /tmp/dev-schema.sql

# Fill it with a bounded, referentially complete sample of production
./deploy/dev-db/seed.sh          # 50 properties by default

# A psql prompt
docker exec -it fd-dev-postgres psql -U postgres -d warehouse

# From your laptop
ssh -L 25433:127.0.0.1:25433 root@143.198.20.37

# Start completely over
docker rm -f fd-dev-postgres && docker volume rm fd-dev-pgdata

scripts/seed_dev_warehouse.py copies buildings → floorplans → units → rent_observations → refresh_policy for properties drawn from client_buildings, so every seeded property is one the API can actually return. refresh_settings is deliberately not copied — it ships empty on purpose, and a seeded copy would be a second source that could drift.

Three guards stop it writing the wrong way round: the source session sets default_transaction_read_only, the target host is refused if it looks like managed production, and columns are intersected between source and target so schema drift narrows the copy instead of misaligning it.

pgbouncer

fd-pgbouncer now sits on both the default bridge and the coolify network, so containers reach it as fd-pgbouncer:5432 rather than hairpinning out through public port 25432.

Two config changes, both in /root/fd-pgbouncer/pgbouncer.ini (backup: pgbouncer.ini.bak-preselfhost):

  • max_client_conn 20 → 100. Client-side sockets are cheap, and during cutover both the Vercel copy and the droplet copy connect here.
  • max_db_connections = 12 (new). The guard that makes the above safe: it caps total server-side backends per database across all users, which default_pool_size (per database and user) cannot. Without it, raising client connections is how you exhaust a 25-connection cluster.

Reload with docker kill -s SIGHUP fd-pgbouncer — it re-reads config in place and does not drop sessions.

Three things that will bite you

Postgres 18 moved its data directory. PGDATA is now /var/lib/postgresql/18/docker, and the volume must mount one level up at /var/lib/postgresql. Mount the old /var/lib/postgresql/data and the image refuses to start rather than initialising a shadowed second cluster.

The deployed daemon image lags main. It is built at whatever commit was current, so migrating anything from it produces a schema older than the code you are deploying. This surfaced as column bi.latitude does not exist while creating a view — latitude was added in f75e1a9, after the running image's 853a5bf. Schema for the dev database therefore comes from scripts/dump_schema_ddl.py in the checkout being deployed, never from the image.

The daemon image's ENTRYPOINT ignores its arguments. deploy/Dockerfile sets ENTRYPOINT ["/entrypoint.sh"], so docker run <image> buildings-scraper db-migrate does not run that command — entrypoint.sh runs its own migration and then boots supercronic. The container never exits and you are left with a second cron scheduler running the outreach jobs against whatever database you pointed it at. Always pass --entrypoint buildings-scraper.

What is still pending

  1. No alerting. If a container or the droplet dies, nothing tells anyone. The healthchecks restart containers, but nobody is paged.
  2. The frontends still read the Vercel copy. Repointing them is one variable each — INTERNAL_API_BASE for foundational-data-frontend, INTERNAL_API_URL for the dashboard — plus making sure the INTERNAL_API_KEY they hold matches the droplet's.
  3. The outreach daemon still deploys manually, on an SSH deploy key rather than the GitHub App.
  4. No edge in front of the client API. Cloudflare in proxied mode would restore the DDoS absorption and caching that Vercel provided, and give a kill-switch that does not wait on DNS TTL.