Author SHA1 Message Date
Igor K.andClaude Sonnet 5.5 b1ae4f1daa fix: make the database disk image 6 GB instead of 2 GB
- VOLUMES["db"] goes from 2048 to 6144 MiB, so every new instance gets a 6 GB database image.
  Instance 4 filled its 2 GB image with analytics logs on 2026-10-14: Postgres crashed and could
  not finish crash recovery without free space. Instance 2 had already been grown to 6 GB by hand.
- Measured on instance 2: about 1.2 GB of logs a day under browser-test traffic, kept for
  LOG_RETENTION (2 days), plus up to 1 GB of write-ahead log. That peaks near 4.5 GB.
- Only instances created after this change get the larger image; existing ones keep their size.
- README disk line, the two unit tests that assert the db volume size, and the version
  (1.6.3 -> 1.6.4 in VERSION, supaspin and README.md) are updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:46:50 -04: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 43 additions and 28 deletions
+14 -7
View File
@@ -1,6 +1,6 @@
# supaspin # supaspin
Version 1.6.1 Version 1.6.4
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,7 +16,7 @@ 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 6 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 **9 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 | | 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 |
@@ -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
@@ -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.1 1.6.4
+18 -12
View File
@@ -39,7 +39,7 @@ import tty
import yaml import yaml
VERSION = "1.6.1" VERSION = "1.6.4"
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,10 @@ 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.
VOLUMES = {"db": 6144, "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.
@@ -189,24 +192,27 @@ def buffering_middleware(n):
def ingress_annotations(cfg, n): def ingress_annotations(cfg, n):
"""Ingress annotations for instance n, shaped for the configured ingress controller. """Ingress annotations for instance n.
With Traefik the upload cap lives in a Middleware object created next to the instance Both controllers' annotations are always present: an annotation only takes effect
and the ingress points at it; with nginx it is proxy-body-size, and the chart's own when a controller of that family actually routes the ingress, so on a Traefik
rewrite-target default must be nullified or every API path breaks. The cert-manager cluster the nginx keys sit inert (and vice versa) and nothing needs reconfiguring
annotation orders a certificate from the supaspin-letsencrypt issuer; without it the when the router changes. The upload cap therefore lives in two places that agree:
TLS secret keeps whatever supaspin put there (a saved or a self-signed certificate).""" proxy-body-size for nginx, and the Middleware object the traefik annotation points
at. The chart's own rewrite-target default must be nullified or every API path
breaks. The cert-manager annotation orders a certificate from the
supaspin-letsencrypt issuer; without it the TLS secret keeps whatever supaspin put
there (a saved or a self-signed certificate)."""
anns = {} anns = {}
if cfg.get("lets_encrypt", True): if cfg.get("lets_encrypt", True):
anns["cert-manager.io/cluster-issuer"] = ISSUER anns["cert-manager.io/cluster-issuer"] = ISSUER
if cfg.get("ingress_controller", "nginx") == "traefik":
anns["traefik.ingress.kubernetes.io/router.middlewares"] = \
f"{namespace(n)}-{buffering_middleware(n)}@kubernetescrd"
else:
# The chart default rewrites every path to "/", which breaks all APIs; null removes it. # The chart default rewrites every path to "/", which breaks all APIs; null removes it.
anns["nginx.ingress.kubernetes.io/rewrite-target"] = None anns["nginx.ingress.kubernetes.io/rewrite-target"] = None
anns["nginx.ingress.kubernetes.io/proxy-body-size"] = "50m" anns["nginx.ingress.kubernetes.io/proxy-body-size"] = "50m"
anns["nginx.ingress.kubernetes.io/proxy-read-timeout"] = "3600" anns["nginx.ingress.kubernetes.io/proxy-read-timeout"] = "3600"
anns["nginx.ingress.kubernetes.io/proxy-send-timeout"] = "3600" anns["nginx.ingress.kubernetes.io/proxy-send-timeout"] = "3600"
if cfg.get("ingress_controller", "nginx") == "traefik":
anns["traefik.ingress.kubernetes.io/router.middlewares"] = \
f"{namespace(n)}-{buffering_middleware(n)}@kubernetescrd"
return anns return anns
+8 -6
View File
@@ -143,7 +143,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"], "6144Mi")
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")
@@ -216,13 +216,15 @@ class IngressShaping(unittest.TestCase):
self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-read-timeout"], "3600") self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-read-timeout"], "3600")
self.assertNotIn("traefik.ingress.kubernetes.io/router.middlewares", a) self.assertNotIn("traefik.ingress.kubernetes.io/router.middlewares", a)
def test_traefik_native(self): def test_traefik_also_keeps_nginx_annotations(self):
# The nginx keys must be present in traefik mode too: while nginx routes the
# ingress they enforce the cap, and they are inert the day Traefik takes over.
a = self.ann({"ingress_controller": "traefik"}) a = self.ann({"ingress_controller": "traefik"})
self.assertEqual(a["traefik.ingress.kubernetes.io/router.middlewares"], self.assertEqual(a["traefik.ingress.kubernetes.io/router.middlewares"],
"supaspin-2-supa2-buffering@kubernetescrd") "supaspin-2-supa2-buffering@kubernetescrd")
for nginx_key in ("rewrite-target", "proxy-body-size", "proxy-read-timeout", self.assertIsNone(a["nginx.ingress.kubernetes.io/rewrite-target"])
"proxy-send-timeout"): self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-body-size"], "50m")
self.assertNotIn(f"nginx.ingress.kubernetes.io/{nginx_key}", a) self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-read-timeout"], "3600")
self.assertEqual(a["cert-manager.io/cluster-issuer"], "supaspin-letsencrypt") self.assertEqual(a["cert-manager.io/cluster-issuer"], "supaspin-letsencrypt")
def test_lets_encrypt_off_drops_cert_manager_annotation(self): def test_lets_encrypt_off_drops_cert_manager_annotation(self):
@@ -476,7 +478,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"], "6144Mi")
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"])