# basin

> basin: My own little Neon. Isolated Postgres databases on one VPS, one click each.

URL: https://sayanbiswas.in/p/basin/

- status · live
- started · 2026
- updated · oct 2026
- stack · postgres 18, pgbouncer, fastapi, react, tanstack, shadcn/ui, caddy, ansible

![The basin overview: eight projects, 3.3 GB of storage, live connection count with a sparkline, a capacity ring, and storage broken down by project.](https://sayanbiswas.in/_astro/basin-overview-light.DqKsjTYU_2h1ct7.webp) 

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

*Diagram:* Two paths into one Postgres. Apps connect over TLS to PgBouncer, which pools their connections. The dashboard goes through Caddy to basin-api, which creates and drops each project's database and role.

- **One cluster, hard walls.** A project is a database plus a login role that owns it. `CONNECT` is revoked from everyone else, and the role carries a `CONNECTION 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-down `auth_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 `CREATEDB` and `CREATEROLE`, not as a superuser.

## the dashboard

![The projects table: each project with a monogram, its status, a ring showing connections used against its limit, storage with a bar, and how long ago it was created.](https://sayanbiswas.in/_astro/basin-projects-light.C1226FnU_17H5XY.webp) 

*Every project at a glance: who's active, how close each one is to its connection limit, and where the storage goes. Synthetic data.*

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.

![The analytics project page: 1.7 GB of storage at 52% of the cluster, 21 of 40 connections with a ring, a live activity chart, the connection string, and backup, rotate and delete actions.](https://sayanbiswas.in/_astro/basin-detail-light.BQu7xB2__Z2axh8L.webp) 

*One project: storage, connections against its limit, live activity, how to connect, and the dangerous buttons. Synthetic data.*

Creating a project shows the password exactly once. basin keeps no copy, so a lost password means a rotation, never a lookup.

![The new project sheet with a name, a connection limit of 25 and a description.](https://sayanbiswas.in/_astro/basin-create-light.BC5kXnL__usvGT.webp) 

*Name, connection limit, optional description. Synthetic data.*

![The connection details dialog with a shown-only-once warning, a copyable connection string and password, and host, port, database and user.](https://sayanbiswas.in/_astro/basin-secret-light.-l1xYsHO_kHD49.webp) 

*The only time the password is shown. Synthetic data.*

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.

![The command palette open over the projects page, listing actions, pages and projects with their sizes.](https://sayanbiswas.in/_astro/basin-palette-light.BNJ-iEbX_ZEUgLt.webp) 

*⌘K or / for everything. Synthetic data.*

It’s laid out for a phone too, with the same floating tab bar.

![Three phone screens: the overview with stat tiles and a storage breakdown, the projects list with connection rings, and a project page with its activity chart.](https://sayanbiswas.in/_astro/basin-mobile-light.BOrOVfeo_1mLbLo.webp) 

*Overview, projects and a project page on a phone. Synthetic data.*

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](https://opaline.buildlab.in) and the animated icons from [lucide-animated](https://lucide-animated.com), 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](https://sayanbiswas.in/blog/basin-managed-postgres-on-one-box).

## 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.
