Console Login

Self-Hosting Supabase with Docker Compose and Two Crash Loops Fixed

What breaks

The supabase/supabase repository (Apache-2.0 license) ships an official Docker Compose setup for self-hosting in its docker directory. The task gave the latest push-triggered run on master, the "Studio Docker Build" workflow, as successful. I did not inspect what that workflow covers. What I wanted to know was simpler: does a fresh machine reach a fully healthy stack with the documented commands?

The sandbox

This is a clean Linux container provided by the session, not a server I chose or configured beforehand. I only report what the commands below printed, and nothing about where it runs or what hardware is behind it. These are the first commands I ran, and exactly what they printed.

uname -a
Linux vm 6.18.44-fc-v378 #1 SMP PREEMPT_DYNAMIC @0 x86_64 x86_64 x86_64 GNU/Linux
cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.5 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.5 LTS (Noble Numbat)"

Docker Engine 29.9.2 and the Compose plugin were installed, but the daemon was not running. docker info reported that it could not connect to /var/run/docker.sock, so I started it by hand with dockerd in the background, as root, and checked the result.

(nohup dockerd > /tmp/dockerd.log 2>&1 &)
docker info 2>&1 | grep -E "Server Version|Storage Driver|Cgroup"
 Server Version: 29.9.2
 Storage Driver: overlayfs
 Cgroup Driver: cgroupfs
 Cgroup Version: 1

The symptom

I cloned the repository with git clone --depth 1 (commit 86f9c0c2 on master), then followed the docker README: the bootstrap script setup.sh creates a project directory, generates secrets and keys, and pulls images. Then sh run.sh start brings the stack up and waits for health checks. It finished with one line that matters.

sh run.sh start
container supabase-pooler is unhealthy

The status listing from sh run.sh status showed the real picture: nine of eleven containers were healthy, but the connection pooler (Supavisor) and the Edge Functions runtime were both marked "Restarting" and kept restarting. The database, PostgREST, Auth, Storage, Realtime, Studio and the gateway were up, so a quick look at Studio would not have revealed anything wrong.

The cause

The two containers fail for two unrelated reasons, and both come from the environment around the stack rather than from the stack itself. I read the logs of each one. I cannot prove either problem exists on other machines. Both are the compose defaults meeting limits that this particular sandbox enforces.

Pooler: a file descriptor limit the container cannot raise

The Supavisor log repeats the same two lines over and over. The container entrypoint tries to raise the open-files limit to 100000, the kernel refuses with "Operation not permitted", and the container exits, so the restart policy starts the same sequence again.

docker logs supabase-pooler 2>&1 | tail -30
Setting RLIMIT_NOFILE to 100000
/app/limits.sh: line 6: ulimit: open files: cannot modify limit: Operation not permitted

The value comes from the image, not from the compose file. Printing the image environment shows RLIMIT_NOFILE=100000 baked in, and the entrypoint script, /app/limits.sh, is a short wrapper around ulimit -n. Limits are inherited from the process that starts the container, so I checked what a plain container gets here: both the soft and the hard limit are 20000, below the 100000 requested.

docker run --rm busybox sh -c 'ulimit -n; ulimit -Hn'
20000
20000

Functions: TLS interception that the container does not trust

The Edge Functions log shows a different failure. The main worker cannot start, because its first import is a JSR package, and the TLS handshake to jsr.io fails with an unknown issuer before any function code can run. The runtime then exits and is restarted.

docker logs supabase-edge-functions 2>&1 | tail -15
    3: JSR package manifest for '@panva/jose' failed to load. Import 'https://jsr.io/@panva/jose/meta.json' failed: error sending request for url (https://jsr.io/@panva/jose/meta.json): client error (Connect): invalid peer certificate: UnknownIssuer

The sandbox host itself trusts extra CA certificates, and the certificate presented for jsr.io is issued by an egress gateway CA. In other words, outbound TLS is re-signed on the way out, and the host trusts that CA while the container's Deno runtime does not.

echo | openssl s_client -connect jsr.io:443 -servername jsr.io 2>/dev/null | openssl x509 -noout -issuer
issuer=O = Anthropic, CN = Egress Gateway SDS Issuing CA (production)

Source of truth

For the compose behaviour I relied on the files in the repository itself, which I printed in the sandbox, and on the upstream Deno documentation for the TLS setting. The repository's docker-compose.yml and the header of run.sh are the authority for how the stack is layered. The README lives in the docker directory.

What the repository confirms

The compose file already contains a precedent for the pooler fix. The Realtime service pins its own descriptor limit through an environment variable instead of relying on whatever default its image carries, and the value it picks, 10000, is below the cap I measured above.

sed -n 318,326p docker-compose.yml
      RLIMIT_NOFILE: "10000"

The header of run.sh documents the supported way to customise the stack: extra compose files are listed in COMPOSE_FILE in .env, managed with sh run.sh config add <name>. That means a local override file is a first-class mechanism, not a hack, and it keeps the vendor docker-compose.yml untouched for the repository's update.sh merge.

What Deno documents

The Deno 1.13 release notes introduced DENO_TLS_CA_STORE for networks that re-sign TLS traffic. They state that setting it to system makes Deno trust the system root CA store, and that on Linux the bundle is read from well-known paths on disk. They also warn against disabling certificate verification, which rules out the unsafe flag as a fix.

Possible solutions

Each problem has more than one way out. I tested what I could in this sandbox, and I say below which options I tried and which I only reasoned about. Only the tried ones count as measured, and each option is judged on that basis.

For the pooler

  • Raise the container limit with --ulimit or the compose ulimits key. I tried it with docker run --rm --ulimit nofile=100000:100000 busybox, and the runtime refused to create the container with "error setting rlimit type 7: operation not permitted".
  • Raise the Docker daemon limit. This depends on the privileges of the host, and I did not try it, since the per-container attempt below already showed that the runtime refuses values above the cap.
  • Override RLIMIT_NOFILE for the pooler to a value under the cap, in a compose override file.
  • Edit docker-compose.yml directly. It works, but it creates a merge conflict on every update.

For the functions runtime

  • Point DENO_CERT at a CA bundle. I tried this first, and it had no effect on the error here.
  • Set DENO_TLS_CA_STORE=system and give the runtime a CA bundle that includes the intercepting CA.
  • Disable certificate verification. I rejected it for the security reason above.
  • Do not run the functions service at all, which loses a feature of the stack.

Why this fix

I chose one compose override file, docker-compose.local.yml, registered through the repository's own run.sh config add. It fixes both containers without touching a vendor file, it survives update.sh, and it keeps certificate verification on. The pooler value of 10000 follows the Realtime precedent in the same file, and it sits comfortably above the 100 client connections and pool size of 20 that the generated .env allows.

For the functions runtime, DENO_TLS_CA_STORE=system alone was not enough in my test: the same unknown issuer error came back. Pointing SSL_CERT_FILE at the mounted host bundle as well made the runtime start and stay up when I ran the image directly with docker run. That is the combination I put into the override.

Step by step

This is the sequence in the sandbox, from the bootstrap to the verification, in the order I ran it, including one detour that was my own fault. All commands after the bootstrap were run from the project directory that setup.sh created, here /tmp/work/sb, where .env lives.

Bootstrap

I ran the repository's setup script from the clone, with prompts disabled, system package installation skipped (Docker and git were already present) and a fixed directory name. It generated the secrets, the key pair and the publishable and secret API keys, pulled the images, and stamped the release tag that the docker directory was taken from.

sh /tmp/supabase/docker/setup.sh -y --skip-deps --project-dir sb
===> Recorded base version in .supabase-version (ref=self-hosted/v0.8.4)
===> Pulling Docker images
Setup complete. Project ready at: /tmp/work/sb

Note that the script cloned the latest self-hosted/v* tag, not master, so the stack I tested is release v0.8.4 rather than the commit I had cloned. I did not compare the two versions of the docker directory, so I cannot say whether they differ.

A mistake of mine that looks like a bug

While probing the stack I sourced .env into my own shell to read the keys. This happened right after I added the override described in the next subsection, but it is easier to explain first. That was my error: the shell strips the double quotes from JWT_KEYS, and Compose gives shell variables precedence over .env. After the next restart, Auth failed on a configuration parse error for GOTRUE_JWT_KEYS, and Storage and Realtime were crash-looping as well. The check below shows the damaged value in my shell.

echo "${JWT_KEYS:0:30}"
[{kty:EC,kid:41e46ca0-2bf9-4bd

I unset every variable that .env defines with the loop below, started the stack again with sh run.sh start, and Auth, Storage and Realtime came back healthy. From then on I read single values with grep and cut instead of sourcing the file.

for v in $(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env | cut -d= -f1); do unset $v; done

The override file

I created the override and printed it back, so the text below is what the stack actually used. The first version of the file used DENO_CERT for the functions service and did not change the error; this is the second version, which carries the settings I had tested directly with docker run.

cat docker-compose.local.yml
services:
  supavisor:
    environment:
      # The image bakes in RLIMIT_NOFILE=100000; this host caps containers at 20000.
      RLIMIT_NOFILE: "10000"
  functions:
    environment:
      # Use the system trust store, pointed at the host CA bundle (includes the egress CA).
      DENO_TLS_CA_STORE: system
      SSL_CERT_FILE: /etc/ssl/host-ca-bundle.crt
    volumes:
      - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/host-ca-bundle.crt:ro

Registering it with the repository's own helper writes the layered file list into the COMPOSE_FILE line of .env, which is the mechanism described in the run.sh header. Compose then merges the two files, so the override only needs to contain the keys it changes.

sh run.sh config add local
COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml

Restart and verify

After sh run.sh start the stack was recreated, and all eleven containers reported healthy, including the two that had been looping. The Compose project network is the one I use for the pooler test further down. The listing below shows five of the eleven, including the two that failed before.

docker ps -a --format 'table {{.Names}}\t{{.Status}}'
NAMES                            STATUS
supabase-edge-functions          Up 5 seconds (healthy)
supabase-storage                 Up About a minute (healthy)
supabase-pooler                  Up About a minute (healthy)
supabase-db                      Up 3 minutes (healthy)
supabase-studio                  Up 3 minutes (healthy)

A healthy status is not proof that the limit took effect, so I looked at the Erlang VM process inside the pooler container. Its open-files limit is 10000, which is the value from the override, and the earlier "Operation not permitted" error no longer appears in the pooler log.

docker exec supabase-pooler sh -c 'for p in /proc/[0-9]*; do if grep -q beam $p/comm 2>/dev/null; then echo $p; grep "open files" $p/limits; fi; done'
/proc/134
Max open files            10000                10000                files

Then real requests. I created a table in the database, granted read access to the anon role, and read it back through the gateway with the publishable key. The first attempt returned a schema cache error (PGRST205) because PostgREST had not yet noticed the new table; after a few seconds the same request succeeded.

curl -s -w '\n%{http_code}\n' -H "apikey: $PUB" http://localhost:8000/rest/v1/notes
[{"id":1,"body":"hello from the sandbox"}]
200

The Edge Functions runtime, the container that could not boot before, now answers the sample hello function that ships in the repository, with HTTP status 200. The request goes through the gateway on port 8000 with the same publishable key.

curl -s -w '\n%{http_code}\n' -H "apikey: $PUB" http://localhost:8000/functions/v1/hello
{"message":"Hello from Edge Functions!"}
200

The pooler had not served a direct request yet, and healthy is again not the same as working. I ran a query through its transaction-mode port 6543 from a throwaway container on the compose network, using the tenant id from .env. The one row I inserted earlier came back.

docker run --rm --network supabase_default -e PGPASSWORD="$PW" supabase/postgres:17.6.1.136 psql -h supavisor -p 6543 -U "postgres.$TID" -d postgres -Atc "select count(*) as notes from public.notes"
1

The repository's own smoke test

The docker directory has a smoke test script that exercises Studio, Auth, REST, Storage including resumable uploads, Edge Functions, pg-meta, Realtime and the pooler. It finished inside the 250 second timeout I gave it and passed completely, including the check that the functions container has not restarted.

sh tests/test-self-hosted.sh
  PASS: All containers healthy
  PASS: Call hello function
  PASS: Functions container has not restarted
  PASS: Pooler transaction-mode query (as postgres)
=== Results: 42 passed, 0 failed ===

Short review

What worked: the stack from the official scripts reached eleven healthy containers once two environment-specific problems were handled in a separate override file, and the repository's smoke test passed with 42 checks. What I did not do: I did not run the other scripts in the tests directory, such as the Postgres 17 upgrade and S3 backend tests, and I did not test the Studio UI in a browser, only that it answers behind basic auth.

Limits of this fix

The override encodes facts about this sandbox: a descriptor ceiling of 20000 and a TLS-intercepting egress. On a host without those, you do not need it, and mounting a host CA bundle into the functions container is only appropriate when you trust that bundle. I did not test a normal host, so I cannot say how common either problem is. A pooler limit of 10000 is also lower than the image default, which matters if you expect very many client connections.

Other things I hit

Public sign-up returned a 500 on the first try. The Auth log showed it could not resolve the supabase-mail host, the default SMTP target, and the smoke test reports the check as skipped because autoconfirm is off. You need a real SMTP server or autoconfirm for sign-up to work. The pooler tenant id is also a placeholder by default.

When to choose another method

If you control the Docker daemon, raising its open-files limit is cleaner than lowering the pooler's setting. If the functions only need a few public packages, vendoring them removes the network dependency at boot. Behind a re-signing proxy you control, baking your CA into an image is sturdier than mounting a file.

Recent Searches