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_conn20 → 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, whichdefault_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¶
- No alerting. If a container or the droplet dies, nothing tells anyone. The healthchecks restart containers, but nobody is paged.
- The frontends still read the Vercel copy. Repointing them is one variable each —
INTERNAL_API_BASEforfoundational-data-frontend,INTERNAL_API_URLfor the dashboard — plus making sure theINTERNAL_API_KEYthey hold matches the droplet's. - The outreach daemon still deploys manually, on an SSH deploy key rather than the GitHub App.
- 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.