Author SHA1 Message Date
igorandClaude Opus 5.5 7184e7b578 fix: give each instance's Postgres a 12 GB disk image and a 4 GB memory limit (#16)
- db disk image 6144 -> 12288 MiB (VOLUMES["db"]): instance 4 had reached
  4.7 GB of its 6 GB and instances kept being recreated when the image filled.
- db memory request/limit 512/2048 -> 1024/4096 MiB (MEMORY["db"]); the
  per-instance totals become 2.375 GiB requested and 11 GiB limit.
- README (disk and memory rows, totals) and the unit tests follow the new
  numbers; release 1.6.5.

Only instances created afterwards get the bigger disk image: existing ones
have to be deleted and created again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:50:08 +00:00
igor e2afacb5fc fix: make the database disk image 6 GB instead of 2 GB (#15) 2026-10-14 12:47:07 +00:00
igor da8f02310a docs: bring the README in line with the last commits, release 1.6.3 (#14) 2026-10-09 17:46:54 +00:00
igor ccc49ca7d1 fix: keep the nginx ingress annotations when the ingress controller is traefik (#13) 2026-10-09 17:43:21 +00:00
4 changed files with 36 additions and 22 deletions
+17 -10
View File
@@ -1,6 +1,6 @@
# supaspin # supaspin
Version 1.6.2 Version 1.6.5
Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a
single-node [microk8s](https://microk8s.io) server, from a terminal menu. single-node [microk8s](https://microk8s.io) server, from a terminal menu.
@@ -16,8 +16,8 @@ endangering them. Nothing in it is specific to any one app.
| Web API | `https://<slot hostname>` (auth, rest, graphql, realtime, storage, functions); the certificate is from Let's Encrypt when that is enabled, otherwise self-signed | | Web API | `https://<slot hostname>` (auth, rest, graphql, realtime, storage, functions); the certificate is from Let's Encrypt when that is enabled, otherwise self-signed |
| Postgres | `<LAN IP>:544N` (instance 1 → 5441, 2 → 5442, …); which addresses listen is set by **Port bind** | | Postgres | `<LAN IP>:544N` (instance 1 → 5441, 2 → 5442, …); which addresses listen is set by **Port bind** |
| Studio (admin UI) | `http://<LAN IP>:300N` (3001, 3002, …), not on the public hostname | | 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** | | Disk | fixed-size ext4 image files, mounted at boot: **db 12 GB, storage 1 GB, every other volume 256 MB** |
| Memory | limits add up to **9 GiB** per instance, requests to 1.875 GiB (table below) | | Memory | limits add up to **11 GiB** per instance, requests to 2.375 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 | | 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 | | 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 | | Uploads | stored in the instance's own storage image (no MinIO); deleted with the instance |
@@ -33,7 +33,9 @@ of instances at once. Numbers are reused: create picks the lowest free one.
- A Debian/Ubuntu server with **microk8s** (or any Kubernetes with `kubectl` and `helm` on the - A Debian/Ubuntu server with **microk8s** (or any Kubernetes with `kubectl` and `helm` on the
path), with an ingress controller installed: **ingress-nginx** or **Traefik** (the microk8s path), with an ingress controller installed: **ingress-nginx** or **Traefik** (the microk8s
`ingress` addon is either, depending on the version). `ingress` addon is either, depending on the version). With **Traefik**, its CRDs must be present
on the cluster — they come with the Traefik installation itself; supaspin creates `Middleware`
objects and does not install cluster-wide types.
- With **Let's Encrypt** enabled: **cert-manager** installed, DNS `A` records for every hostname - With **Let's Encrypt** enabled: **cert-manager** installed, DNS `A` records for every hostname
slot pointing at the server, and ports 80 and 443 forwarded to it (Let's Encrypt fetches a test 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). Without it: no cert-manager, no public DNS — each file over port 80 to issue each certificate). Without it: no cert-manager, no public DNS — each
@@ -77,7 +79,7 @@ sudo supaspin
| Server LAN IP | address shown for the Postgres and Studio ports (detected) | | Server LAN IP | address shown for the Postgres and Studio ports (detected) |
| Port bind | `lan` = LAN IP only (default; keeps Postgres and the login-less Studio off a public interface), `all` = every interface incl. localhost (for a box behind NAT running its own apps), `local` = localhost only | | Port bind | `lan` = LAN IP only (default; keeps Postgres and the login-less Studio off a public interface), `all` = every interface incl. localhost (for a box behind NAT running its own apps), `local` = localhost only |
| Ingress class | usually `public` on microk8s | | Ingress class | usually `public` on microk8s |
| Ingress controller | `traefik` or `nginx`; Traefik instances get a Middleware that caps uploads at 50 MiB, nginx gets the equivalent annotations | | Ingress controller | `traefik` or `nginx`; every instance's Ingress carries the nginx annotations (50 MiB upload cap, long timeouts) and, in Traefik mode, additionally points at a `Middleware` object with the same 50 MiB cap - so the cap is enforced no matter which of the two actually routes the hostname |
| Let's Encrypt | `y` fetches a real certificate per hostname (needs public DNS, ports 80/443, cert-manager); `n` gives every instance a self-signed certificate and creates no cert-manager objects | | Let's Encrypt | `y` fetches a real certificate per hostname (needs public DNS, ports 80/443, cert-manager); `n` gives every instance a self-signed certificate and creates no cert-manager objects |
| Let's Encrypt e-mail | for certificate expiry notices (asked only when Let's Encrypt is on) | | Let's Encrypt e-mail | for certificate expiry notices (asked only when Let's Encrypt is on) |
| Hostname slots | comma-separated; a name with a number in it keys that slot (`supa4.didgibot.com` = slot 4, so instance 4 gets it with ports 5444/3004), a plain name takes its position in the list | | Hostname slots | comma-separated; a name with a number in it keys that slot (`supa4.didgibot.com` = slot 4, so instance 4 gets it with ports 5444/3004), a plain name takes its position in the list |
@@ -115,12 +117,12 @@ sudo supaspin env N # the full .env (contains secrets)
sudo supaspin tunnel N # the tunnel commands, step by step sudo supaspin tunnel N # the tunnel commands, step by step
sudo supaspin push-env N TARGET [--port P] [--key FILE] [--dry-run] # see "Copying an instance's values" sudo supaspin push-env N TARGET [--port P] [--key FILE] [--dry-run] # see "Copying an instance's values"
sudo supaspin delete N [--yes] sudo supaspin delete N [--yes]
sudo supaspin upgrade [N] # apply this version's memory caps to running instances sudo supaspin upgrade [N] # apply this version's memory caps and log retention to running instances
sudo supaspin suspend N # stop an instance, keep its data (see "Suspending an instance") sudo supaspin suspend N # stop an instance, keep its data (see "Suspending an instance")
sudo supaspin resume N # start it again sudo supaspin resume N # start it again
sudo supaspin mail # change the saved default mail settings sudo supaspin mail # change the saved default mail settings
sudo supaspin setup # change settings sudo supaspin setup # change settings
supaspin --version # prints "supaspin 1.3.1" supaspin --version # prints "supaspin X.Y.Z"
``` ```
### Connecting your app ### Connecting your app
@@ -268,13 +270,13 @@ stores the new memory limits and leaves it asleep; they take effect when you res
| Service | Request | Limit | Highest use measured | | Service | Request | Limit | Highest use measured |
|---|---|---|---| |---|---|---|---|
| db | 512 MiB | 2048 MiB | 273 MiB | | db | 1024 MiB | 4096 MiB | 273 MiB |
| analytics | 128 MiB | 1536 MiB | 694 MiB | | analytics | 128 MiB | 1536 MiB | 694 MiB |
| vector | 128 MiB | 512 MiB | 290 MiB | | vector | 128 MiB | 512 MiB | 290 MiB |
| kong | 128 MiB | 768 MiB | 269 MiB | | kong | 128 MiB | 768 MiB | 269 MiB |
| realtime | 128 MiB | 768 MiB | 300 MiB | | realtime | 128 MiB | 768 MiB | 300 MiB |
| auth, functions, imgproxy, meta, rest, storage, studio | 128 MiB each | 512 MiB each | 203 MiB (studio) | | auth, functions, imgproxy, meta, rest, storage, studio | 128 MiB each | 512 MiB each | 203 MiB (studio) |
| **Total** | **1.875 GiB** | **9 GiB** | | | **Total** | **2.375 GiB** | **11 GiB** | |
The request is what the scheduler sets aside for a service and can never be given to anything else. The request is what the scheduler sets aside for a service and can never be given to anything else.
The limit is the ceiling the kernel kills the container at. They do different jobs here. The limit is the ceiling the kernel kills the container at. They do different jobs here.
@@ -300,7 +302,9 @@ every delivery with an error, and vector was killed against its 1024 MiB ceiling
minutes with the undeliverable backlog. The fix is log retention: every instance gets a pg_cron minutes with the undeliverable backlog. The fix is log retention: every instance gets a pg_cron
job (set up on create, upgrade and resume) that deletes analytics logs older than **2 days** every job (set up on create, upgrade and resume) that deletes analytics logs older than **2 days** every
night. pg_cron in this image only runs jobs from the `postgres` database, so the job lives there night. pg_cron in this image only runs jobs from the `postgres` database, so the job lives there
and reaches the log tables in `_supabase` over a local dblink connection. That bounds both the and reaches the log tables in `_supabase` over a local dblink connection. The dblink extension
and the helper function live in the `extensions` schema, where the app's database owner has no
rights, so instance code can never call them. That bounds both the
db image and vector's backlog, so vector is back to the 512 MiB ceiling the production supabase db image and vector's backlog, so vector is back to the 512 MiB ceiling the production supabase
charts carry. charts carry.
@@ -317,6 +321,9 @@ pods are replaced service by service and nothing else about the instance changes
- **Certificate stays pending.** The hostname's DNS record must point at the server and port 80 must - **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`. 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). - **"no free hostname slot".** Delete an instance or add a slot in Settings (and its DNS record).
- **Create failed** with `no matches for kind "Middleware" in version "traefik.io/v1alpha1"`. The
config says Traefik but the cluster has no Traefik CRDs: either install Traefik (its CRDs come
with it) or set the ingress controller to `nginx` in Settings.
- **Create failed.** `sudo supaspin delete N`, then create again. - **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`); - **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. another program is using it. Free the port or use another instance number.
+1 -1
View File
@@ -1 +1 @@
1.6.2 1.6.5
+10 -4
View File
@@ -39,7 +39,7 @@ import tty
import yaml import yaml
VERSION = "1.6.2" VERSION = "1.6.5"
CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml") CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml")
STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin") STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin")
CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes" CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes"
@@ -53,7 +53,12 @@ CERTS_NS = "supaspin-certs"
# Image cap per volume, in MiB. The chart's separate imgproxy volume is not used: imgproxy has to # Image cap per volume, in MiB. The chart's separate imgproxy volume is not used: imgproxy has to
# read the uploaded files, so it mounts the storage volume instead (see instance_values). # read the uploaded files, so it mounts the storage volume instead (see instance_values).
VOLUMES = {"db": 2048, "storage": 1024, "functions": 256, # The db image is 6 GB because of the analytics logs: browser-test traffic wrote about 1.2 GB of
# logs a day on instance 2 (2026-10-14), the nightly cleanup keeps LOG_RETENTION of them, and the
# write-ahead log may take up to 1 GB on top. That peaks near 4.5 GB; 2 GB filled up on instance 4.
# Doubled to 12 GB on 2026-11-03 at Igor's request: instance 4 had reached 4.7 GB of its 6 GB and
# instances kept having to be recreated when the image filled up.
VOLUMES = {"db": 12288, "storage": 1024, "functions": 256,
"snippets": 256, "deno": 256, "pgsodium": 256} "snippets": 256, "deno": 256, "pgsodium": 256}
# The user Postgres runs as inside supabase/postgres 17.6.1.136 (`id postgres` in the image). # The user Postgres runs as inside supabase/postgres 17.6.1.136 (`id postgres` in the image).
# Production's older image used 2000; do not copy that number. # Production's older image used 2000; do not copy that number.
@@ -71,7 +76,8 @@ DB_UID, DB_GID = 100, 101
# realtime 269 / 287 / 300 MiB against 384 # realtime 269 / 287 / 300 MiB against 384
# kong 260 / 269 / 265 MiB against 512 # kong 260 / 269 / 265 MiB against 512
# studio 203, storage 187, rest 162 MiB against 256 # studio 203, storage 187, rest 162 MiB against 256
# db 163 / 175 / 273 MiB against 2048 - left alone, it has room # db 163 / 175 / 273 MiB against 2048 - doubled to 1024 / 4096 on 2026-11-03 at Igor's
# request, together with the db image, so a larger database has room to work in
# On 2026-10-06 instance 3 filled its 2 GB database image with logs: analytics (Logflare) never # On 2026-10-06 instance 3 filled its 2 GB database image with logs: analytics (Logflare) never
# deletes what it stored, the db died of it, and vector's undelivered backlog was killed against # deletes what it stored, the db died of it, and vector's undelivered backlog was killed against
# the 1024 MiB ceiling every few minutes. The fix is setup_log_retention: a pg_cron job deletes # the 1024 MiB ceiling every few minutes. The fix is setup_log_retention: a pg_cron job deletes
@@ -81,7 +87,7 @@ DB_UID, DB_GID = 100, 101
# would take the memory away from everything else on the node straight away. # would take the memory away from everything else on the node straight away.
SERVICES = ["db", "analytics", "kong", "auth", "functions", "imgproxy", "meta", SERVICES = ["db", "analytics", "kong", "auth", "functions", "imgproxy", "meta",
"realtime", "rest", "storage", "studio", "vector"] "realtime", "rest", "storage", "studio", "vector"]
MEMORY = {"db": (512, 2048), "analytics": (128, 1536), "kong": (128, 768), "realtime": (128, 768), MEMORY = {"db": (1024, 4096), "analytics": (128, 1536), "kong": (128, 768), "realtime": (128, 768),
"vector": (128, 512)} "vector": (128, 512)}
DEFAULT_MEMORY = (128, 512) DEFAULT_MEMORY = (128, 512)
+8 -7
View File
@@ -109,10 +109,10 @@ class Values(unittest.TestCase):
def test_memory_caps_add_up(self): def test_memory_caps_add_up(self):
req, lim = sp.total_memory_mib() req, lim = sp.total_memory_mib()
# db 2048, analytics 1536, kong 768, realtime 768, vector 512, the other seven 512 each # db 4096, analytics 1536, kong 768, realtime 768, vector 512, the other seven 512 each
self.assertEqual(lim, 2048 + 1536 + 768 + 768 + 512 + 7 * 512) self.assertEqual(lim, 4096 + 1536 + 768 + 768 + 512 + 7 * 512)
self.assertEqual(lim / 1024, 9.0) self.assertEqual(lim / 1024, 11.0)
self.assertEqual(req, 512 + 11 * 128) # 1.875 GiB self.assertEqual(req, 1024 + 11 * 128) # 2.375 GiB
# Measured on three running instances on 2026-09-22; the ceiling has to stay clear of the # Measured on three running instances on 2026-09-22; the ceiling has to stay clear of the
# highest use, or the kernel kills the container in a loop the way it did on instance 1. # highest use, or the kernel kills the container in a loop the way it did on instance 1.
self.assertGreaterEqual(sp.MEMORY["analytics"][1], 1536) # used 694 MiB, died at 768 self.assertGreaterEqual(sp.MEMORY["analytics"][1], 1536) # used 694 MiB, died at 768
@@ -125,8 +125,9 @@ class Values(unittest.TestCase):
self.assertGreaterEqual(sp.DEFAULT_MEMORY[1], 512) # studio used 203 against 256 self.assertGreaterEqual(sp.DEFAULT_MEMORY[1], 512) # studio used 203 against 256
# The requests stay where they were: the node is shared with production and reserving # The requests stay where they were: the node is shared with production and reserving
# what the instances really use would leave production nothing to schedule into. # what the instances really use would leave production nothing to schedule into.
# Only db's went up, from 512 to 1024 MiB, with its limit (2026-11-03).
self.assertTrue(all(m[0] == 128 for s, m in sp.MEMORY.items() if s != "db")) self.assertTrue(all(m[0] == 128 for s, m in sp.MEMORY.items() if s != "db"))
self.assertEqual(req / 1024, 1.875) self.assertEqual(req / 1024, 2.375)
for svc in sp.SERVICES: for svc in sp.SERVICES:
self.assertEqual(self.v["deployment"][svc]["priorityClassName"], "supaspin-low") self.assertEqual(self.v["deployment"][svc]["priorityClassName"], "supaspin-low")
self.assertIn("memory", self.v["deployment"][svc]["resources"]["limits"]) self.assertIn("memory", self.v["deployment"][svc]["resources"]["limits"])
@@ -143,7 +144,7 @@ class Values(unittest.TestCase):
def test_volume_caps(self): def test_volume_caps(self):
p = self.v["persistence"] p = self.v["persistence"]
self.assertEqual(p["db"]["size"], "2048Mi") self.assertEqual(p["db"]["size"], "12288Mi")
self.assertEqual(p["storage"]["size"], "1024Mi") self.assertEqual(p["storage"]["size"], "1024Mi")
for vol in ("functions", "snippets", "deno", "pgsodium"): for vol in ("functions", "snippets", "deno", "pgsodium"):
self.assertEqual(p[vol]["size"], "256Mi") self.assertEqual(p[vol]["size"], "256Mi")
@@ -478,7 +479,7 @@ class Manifests(unittest.TestCase):
pv = sp.pv_manifest(CFG, 2, "db") pv = sp.pv_manifest(CFG, 2, "db")
self.assertEqual(pv["spec"]["claimRef"], {"namespace": "supaspin-2", "name": "supa2-supabase-db"}) self.assertEqual(pv["spec"]["claimRef"], {"namespace": "supaspin-2", "name": "supa2-supabase-db"})
self.assertEqual(pv["spec"]["local"]["path"], "/data/supabase/2/db/data") self.assertEqual(pv["spec"]["local"]["path"], "/data/supabase/2/db/data")
self.assertEqual(pv["spec"]["capacity"]["storage"], "2048Mi") self.assertEqual(pv["spec"]["capacity"]["storage"], "12288Mi")
self.assertEqual(pv["spec"]["persistentVolumeReclaimPolicy"], "Retain") self.assertEqual(pv["spec"]["persistentVolumeReclaimPolicy"], "Retain")
self.assertEqual(pv["spec"]["nodeAffinity"]["required"]["nodeSelectorTerms"][0] self.assertEqual(pv["spec"]["nodeAffinity"]["required"]["nodeSelectorTerms"][0]
["matchExpressions"][0]["values"], ["node1"]) ["matchExpressions"][0]["values"], ["node1"])