Every side project wants a Postgres database. Managed providers are lovely until you have a dozen small apps, each wanting its own branch of someone else’s free tier. I already pay for a VPS, so I built the part of Neon I actually use: click a button, get an isolated database and a connection string that works from Vercel.
basin is a small control plane for one Postgres instance. Each project is its own database, owned by its own login role, with a connection limit so a runaway app can’t starve the rest. Apps reach it over TLS through a PgBouncer pooler, and the dashboard handles create, rotate, back up and delete.
The screenshots use synthetic projects and traffic. The hostname is real, nothing else is.
how it fits together
- One cluster, hard walls. A project is a database plus a login role
that owns it.
CONNECTis revoked from everyone else, and the role carries aCONNECTION LIMIT, so projects can’t see or crowd each other. - No per-project pooler config. PgBouncer runs a
*wildcard and looks passwords up in Postgres through a locked-downauth_query, so a new project works through the pooler the moment it exists. - TLS that verifies. Port 5432 is firewalled shut. The only way in is
6543 with
sslmode=verify-full, using a real Let’s Encrypt certificate that Caddy issues and a timer copies across to PgBouncer. - Least privilege for the control plane. The API connects as a role
with
CREATEDBandCREATEROLE, not as a superuser.
the dashboard
The first version was one plain page. The current one is built for glancing: rings for connection use that turn red near the limit, storage as a share of the cluster, live charts that fill in as stats poll, and a monogram per project so rows are easy to tell apart.
Creating a project shows the password exactly once. basin keeps no copy, so a lost password means a rotation, never a lookup.
Deleting asks you to type the project’s name. Backups are a pg_dump
custom-format archive streamed straight to the browser. Everything is
also a keystroke away in the command palette.
It’s laid out for a phone too, with the same floating tab bar.
The UI is Vite, React and TanStack Router and Query, with a client generated from the API’s OpenAPI spec. The glass parts come from opaline and the animated icons from lucide-animated, both installed through the shadcn CLI.
shipping it
A push to main builds the frontend on GitHub Actions and pipes the
artifacts over SSH to a forced-command key on the box. That key can only
run the deploy script, which unpacks, syncs, restarts the API and fails
the deploy if it doesn’t come back. Nothing is ever built on the server.
Standing up a new server is one Ansible playbook: hardened Postgres, PgBouncer with TLS, Caddy, the API service, the firewall and the deploy key. A migration script moves every project from the old box to the new one. I wrote up the decisions and the one Postgres 16 permission gotcha that bit me in a blog post.
log
- 2026-10-10 · black and silver redesign: project pages, connection rings, live charts, command palette.
- 2026-07-04 · provisioning playbook tested end to end on a throwaway VM.
- 2026-07-03 · Ansible playbook and data migration script for standing up and switching servers.
- 2026-07-03 · push-to-deploy over a forced-command SSH key; one-click backups.
- 2026-07-03 · first version: create, rotate and delete, behind TLS PgBouncer.