Adds a terminal app that creates, lists, shows and deletes numbered Supabase instances on a single-node microk8s server, for testing and staging copies of any app. Nothing in it is specific to one app. - supaspin: single-file Python app (menu and commands: create, list, show, env, tunnel, delete, mail, setup). Instance N gets namespace supaspin-N, hostname slot N, Postgres on <LAN IP>:544N and Studio on <LAN IP>:300N. Lowest free number is reused; a refused create says why. - Disk: fixed-size ext4 image files, loop-mounted from /etc/fstab. db 2 GB, storage 1 GB, functions/snippets/deno/pgsodium 256 MB each. imgproxy mounts the storage volume (the chart's own imgproxy volume cannot see uploads, so image resizing failed). Empty mount points are made read-only until mounted so a pod started early after a reboot cannot initialise a database there. - Memory: limits 5.625 GiB, requests 1.875 GiB per instance, all 12 services. Half of a production setup, except analytics (768 MiB) and realtime (384 MiB), which were killed for running out of memory at half of it. - Priority class supaspin-low (-100, never preempts) on every instance pod. - Public HTTPS through the existing nginx ingress with Let's Encrypt. The Studio and pg-meta routes are cut out of the Kong gateway config by a post-renderer, which also pins the db and studio pods to fixed host ports. - Certificates: delete keeps the TLS secret (with its cert-manager issuer annotations) in namespace supaspin-certs and create restores it, so create/delete cycles do not hit Let's Encrypt's 5-per-week limit. - Secrets generated per instance: JWT secret, anon and service keys, Postgres and Studio passwords, realtime/meta/logflare keys. The rendered values file is removed after install; secrets are never written through kubectl apply. - Instance .env (mode 600) with the usual Supabase variable names, plus an SSH tunnel command and DATABASE_URL help. - Delete is idempotent and finds the real mounts instead of trusting a list, so half-created and older instances clean up completely. A failed create offers to run it. - Mail settings: saved defaults changeable from the menu; create asks to use them or enter different ones for that instance. - install.sh: installs the app; accepts a Gitea token or a local checkout because git.didgibot.com requires sign-in even for public repositories. uninstall.sh: removes the app and, after confirmation, every instance. - tests: 30 unittest cases (numbering, ports, JWT signing, values, .env, fstab handling, post-renderer, certificate secret handling). - README: install, first-run setup, menu, tunnel, troubleshooting, uninstall. Pinned chart: supabase-community/supabase-kubernetes 0.8.0 (Postgres 17.6.1.136). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.2 KiB
supaspin
Create, list, show and delete throwaway Supabase instances on a single-node microk8s server, from a terminal menu.
Use it for testing and staging copies of an app. Each instance is numbered, has its own hostname, its own database, capped disk and capped memory, and runs beside your other workloads without endangering them. Nothing in it is specific to any one app.
What each instance gets
| Web API | https://<slot hostname> (auth, rest, graphql, realtime, storage, functions), with a Let's Encrypt certificate |
| Postgres | <LAN IP>:544N (instance 1 → 5441, 2 → 5442, …), bound on the LAN address only |
| Studio (admin UI) | http://<LAN IP>:300N (3001, 3002, …), not on the public hostname |
| Disk | fixed-size ext4 image files, mounted at boot: db 2 GB, storage 1 GB, every other volume 256 MB |
| Memory | limits add up to 5.625 GiB per instance, requests to 1.875 GiB (table below) |
| Priority | pods run at priority -100 (supaspin-low); when the server runs short of memory they are evicted first and never evict anyone else |
| Secrets | generated fresh per instance: JWT secret, anon and service keys, Postgres password, Studio password, and the internal keys |
| Uploads | stored in the instance's own storage image (no MinIO); deleted with the instance |
Studio and pg-meta (the admin UI and its schema API) are removed from the public gateway: /
and /pg/ return 404 on the public hostname. Studio on port 300N has no login of its own, so
keep that port off the internet (do not forward it on your router).
Instance N uses hostname slot N from your settings, so the number of slots is the maximum number of instances at once. Numbers are reused: create picks the lowest free one.
Requirements
- A Debian/Ubuntu server with microk8s (or any Kubernetes with
kubectlandhelmon the path), with an ingress-nginx ingress class and cert-manager installed. - DNS
Arecords for every hostname slot pointing at the server, and ports 80 and 443 forwarded to it (Let's Encrypt fetches a test file over port 80 to issue each certificate). - Root access on that server.
python3andpython3-yaml(the installer adds the latter withapt).
Install
curl -fsSL https://git.didgibot.com/igor/supaspin/raw/branch/main/install.sh | sudo bash
If your Gitea requires sign-in even for public repositories (the download then fails with
"Could not download"), either pass a read-only token (Gitea → Settings → Applications, scope
repository: read):
curl -fsSL -H "Authorization: token $TOKEN" https://git.didgibot.com/igor/supaspin/raw/branch/main/install.sh | sudo SUPASPIN_TOKEN=$TOKEN bash
or clone the repository and install from the checkout:
git clone ssh://git@git.didgibot.com:2226/igor/supaspin.git && sudo bash supaspin/install.sh
Run it again to upgrade. It installs /usr/local/bin/supaspin, its values template and the
uninstaller in /usr/share/supaspin/.
Then start the app; the first run asks for your settings and prepares the cluster:
sudo supaspin
| Setting | Meaning |
|---|---|
| Storage root | folder that holds every instance's images and .env (<root>/<N>/…) |
| Node name | Kubernetes node the volumes are pinned to (detected) |
| Server LAN IP | address the Postgres and Studio ports are bound to (detected) |
| Let's Encrypt e-mail | for certificate expiry notices |
| Ingress class | usually public on microk8s |
| Hostname slots | comma-separated; the first is instance 1, the second instance 2, … |
| Public SSH host / port / user | only used to print the tunnel command |
| Mail settings | default SMTP settings for the auth service (changeable later) |
Settings live in /etc/supaspin/config.yaml (readable by root only). The first run also creates,
only if missing: the supaspin-local-sc StorageClass, the supaspin-letsencrypt ClusterIssuer,
the supaspin-low PriorityClass and the supaspin-certs namespace, and downloads the pinned
Supabase Helm chart (0.8.0, Postgres 17.6.1.136) once into /var/lib/supaspin/charts/.
Using it
sudo supaspin opens the menu (arrow keys, Enter, q to go back).
| Menu item | What it does |
|---|---|
| Create instance | picks the lowest free number, asks whether to use the saved mail settings or enter different ones, creates the disks, installs Supabase, waits until every pod is ready, writes <root>/<N>/.env |
| List instances | number, hostname, pods ready, memory in use, disk use per image, .env path |
| Show instance | the .env (secrets hidden unless you ask), the tunnel command, pod status and recent events |
| Delete instance | asks you to type the number; removes everything (see below) |
| Mail settings | view and change the saved default mail settings |
| Settings | change any first-run setting |
The same actions are available without the menu:
sudo supaspin create [--ask-mail] # --ask-mail: choose saved or different mail settings
sudo supaspin list
sudo supaspin show N # .env with secrets hidden, plus tunnel help
sudo supaspin env N # the full .env (contains secrets)
sudo supaspin tunnel N
sudo supaspin delete N [--yes]
sudo supaspin mail # change the saved default mail settings
sudo supaspin setup # change settings
Connecting your app
Each instance writes <root>/<N>/.env (mode 600, owned by root) with the usual Supabase variable
names: SUPABASE_URL, PUBLIC_SUPABASE_URL, API_EXTERNAL_URL, ANON_KEY,
PUBLIC_SUPABASE_ANON_KEY, JWT_ANON_KEY, SERVICE_ROLE_KEY, JWT_SERVICE_KEY, JWT_SECRET,
JWT_EXPIRY, POSTGRES_*, DATABASE_URL, DASHBOARD_USERNAME, DASHBOARD_PASSWORD and
GOTRUE_SMTP_*. Read it with sudo supaspin env N and copy what your app needs.
The web API is public, so the URL and keys work from anywhere. Postgres and Studio are not public; from outside the server's network use an SSH tunnel:
- On your computer, run the command from
supaspin tunnel Nin its own terminal and leave it open:ssh -N -p 2222 -L 5441:192.168.10.30:5441 -L 3001:192.168.10.30:3001 you@your.server - Put
DATABASE_URL=postgresql://postgres:<password>@localhost:5441/postgresin your app's.env(the password isPOSTGRES_PASSWORDin the instance's.env). - Studio is at
http://localhost:3001.
On the server's own LAN skip the tunnel and use <LAN IP>:5441 and http://<LAN IP>:3001.
Deleting
Delete works on a half-created instance too and can be run again safely. It:
- saves the instance's TLS certificate into namespace
supaspin-certs, - uninstalls the Helm release, then deletes the namespace and the volumes' Kubernetes objects,
- unmounts every image, removes its
/etc/fstabline and deletes<root>/<N>/.
Creating that instance again restores the saved certificate, so repeated create/delete cycles do
not hit Let's Encrypt's limit of 5 certificates per hostname per week. If a create fails midway,
run supaspin delete N to clean up.
Memory per instance
| Service | Request | Limit |
|---|---|---|
| db | 512 MiB | 2048 MiB |
| analytics | 128 MiB | 768 MiB |
| kong | 128 MiB | 512 MiB |
| realtime | 128 MiB | 384 MiB |
| auth, functions, imgproxy, meta, rest, storage, studio, vector | 128 MiB each | 256 MiB each |
| Total | 1.875 GiB | 5.625 GiB |
Most limits are half of what a production setup would use. Analytics and realtime are higher: at half of production they were killed for running out of memory in a loop (measured on a fresh instance: analytics peaks at 624 MiB, realtime at 280 MiB). Image resizing (imgproxy) reads the uploads from the storage volume, so it has no image of its own.
Troubleshooting
- Pods not ready.
sudo supaspin→ Show instance → Pods and events, orkubectl get pods -n supaspin-Nandkubectl describe pod ….OOMKilledmeans a pod hit its memory limit. - Certificate stays pending. The hostname's DNS record must point at the server and port 80 must
reach the ingress:
kubectl get certificate,order,challenge -n supaspin-N. - "no free hostname slot". Delete an instance or add a slot in Settings (and its DNS record).
- Create failed.
sudo supaspin delete N, then create again. - Port already in use. Postgres and Studio ports are fixed per number (
5440+N,3000+N); another program is using it. Free the port or use another instance number. - After a reboot. Images are mounted from
/etc/fstab(nofail, after the storage root is available) and the pods restart on their own. Check the mounts withfindmnt | grep supabase. Kubernetes can start a pod a moment before its image is mounted, so supaspin makes every empty mount-point folder read-only (chattr +i) until the image is mounted over it: an early pod fails and retries instead of setting up a fresh database on the bare folder.
Uninstall
sudo bash /usr/share/supaspin/uninstall.sh
It asks before deleting the instances (their data is gone for good) and before removing the shared cluster objects, then removes the app, its settings and its cache.
Development
The tests use Python's built-in unittest and need no extra packages:
python3 -m unittest discover -s tests
License
MIT, see LICENSE.