Deploying Self-Hosted Supabase with Portainer: A Complete Guide

Deploy self-hosted Supabase with Portainer: stack setup, the volume mount trap, env vars, and backups. Avoid the pitfalls that break fresh installs.

Cover Image for Deploying Self-Hosted Supabase with Portainer: A Complete Guide

Portainer is the default control plane for half the home labs and small-team Docker hosts on the internet, so it's natural to reach for it when you decide to self-host Supabase. Then you paste Supabase's docker-compose.yml into the stack editor, hit deploy, and watch half the containers crash-loop. You're not doing anything wrong — Supabase's compose stack has a structural quirk that breaks the way most people use Portainer, and it catches enough people that there's a long-running GitHub issue asking for an official Portainer stack, and as of October 2026 a docs PR adding Portainer-specific guidance.

This guide walks through a Portainer deployment that actually works, explains why the naive approach fails, and covers the operational gaps you'll want to close afterwards. If you'd rather skip the Docker archaeology entirely, Supascale's installation guide gets you to the same place with one command — but let's do it the Portainer way first.

Why the Copy-Paste Approach Fails

Most compose stacks are self-contained: one YAML file, a few named volumes, done. Supabase's is not. The official docker-compose.yml bind-mounts a whole directory of configuration files from the repository checkout:

  • ./volumes/db/*.sql — database init scripts that create roles, schemas, and the _realtime tenant
  • ./volumes/api/kong.yml — Kong's declarative route config
  • ./volumes/logs/vector.yml — log pipeline config
  • ./volumes/pooler/pooler.exs — Supavisor pooler config
  • ./volumes/functions/ — Edge Functions runtime files

When you paste only the YAML into Portainer's stack editor, none of these files exist on the host. Docker "helpfully" creates empty directories at each missing bind-mount path, so Postgres boots without its init scripts, Kong starts with no routes, and Vector exits immediately because its config file is a directory. The result is the classic Portainer-Supabase failure signature: db unhealthy, analytics restarting, everything downstream stuck in created.

There are two clean ways around this.

Prerequisites

Before starting, make sure you have:

  • A server with Docker and Portainer CE/BE installed — 4 GB RAM and 2 vCPUs is the floor, 8 GB is comfortable (see our system requirements for sizing by workload)
  • Shell access to the Docker host (you need it once, to stage the config files)
  • A domain name if you plan to expose the APIs publicly
  • openssl for generating secrets

Note that shell access is non-negotiable for the bind-mount approach. If your Portainer manages a remote environment you can't SSH into, use the Git repository method below exclusively.

Step 1: Stage the Supabase Files on the Host

SSH into the Docker host and clone the official repo into a stable location — not your home directory, because Portainer's stack lifecycle shouldn't depend on a user account:

sudo mkdir -p /opt/supabase
sudo git clone --depth 1 https://github.com/supabase/supabase /opt/supabase/repo
sudo cp -r /opt/supabase/repo/docker/volumes /opt/supabase/volumes
sudo cp /opt/supabase/repo/docker/.env.example /opt/supabase/.env

This gives you /opt/supabase/volumes with every config file the stack expects.

Step 2: Generate Real Secrets

Never deploy with the example values — the demo JWT_SECRET and its derived keys are public knowledge, and bots scan for them. Generate your own:

# JWT secret (40+ chars)
openssl rand -base64 48

# Postgres password, dashboard password, vault key
openssl rand -base64 32

The ANON_KEY and SERVICE_ROLE_KEY must be JWTs signed with your JWT_SECRET — you can't just random-generate them. Use the official self-hosting docs' JWT generator, or jwt.io with the correct payload (role: anon / role: service_role, 10-year expiry). Getting this wrong produces the second-most-common Portainer deployment failure: everything runs, but every API call returns 401 Invalid authentication credentials.

For a full walkthrough of what each variable controls, see our complete guide to Supabase self-hosted environment variables.

Step 3: Create the Stack in Portainer

You have two options, in order of preference:

Option A: Repository deploy (recommended)

In Portainer: Stacks → Add stack → Repository. Point it at your own Git repo containing a modified docker-compose.yml (fork the official docker/ directory). This gives you GitOps-style redeploys and keeps the stack definition out of Portainer's local database.

The critical modification: change every relative bind mount to an absolute path:

# Before (breaks in Portainer)
volumes:
  - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z

# After
volumes:
  - /opt/supabase/volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z

There are roughly a dozen of these across the db, kong, vector, supavisor, and functions services. Miss one and you get a silent empty-directory mount, so do a grep -n './volumes' docker-compose.yml and confirm it returns nothing when you're done.

Option B: Web editor with absolute paths

Paste the compose file into the stack editor and make the same path substitutions. Workable, but you lose version history and the stack definition lives only inside Portainer — if that Portainer instance dies, your deployment config dies with it. (You are backing up Portainer's own data volume, right?)

Either way, load your environment variables in the Environment variables section — Portainer accepts a direct paste of .env contents via "Load variables from .env file", which beats typing fifty variables by hand.

Step 4: Deploy and Verify

Hit Deploy the stack, then watch the container list. Expected startup order: db goes healthy first (30–60 seconds, longer on slow disks), then analytics, then the rest cascade. A healthy stack shows all services green within about two minutes.

Quick verification from the host:

# Kong answering?
curl -s http://localhost:8000/auth/v1/health \
  -H "apikey: YOUR_ANON_KEY"

# Postgres accepting connections?
docker exec supabase-db pg_isready -U postgres

If db is healthy but auth or rest restart endlessly, the usual suspect is a JWT_SECRET/key mismatch from Step 2. If vector or analytics fail instantly, you've got a bind-mount path that's an empty directory — re-check Step 3.

Step 5: Don't Stop at "It Runs"

A green stack in Portainer is the beginning, not the end:

Put a reverse proxy in front. Kong listens on 8000/8443, but you want TLS termination and real certificates. Our reverse proxy setup guide for Nginx, Traefik, and Caddy covers all three — Caddy is the least-effort option on a Portainer host.

Lock down Studio. The dashboard's basic-auth default is thin protection for a tool that can run arbitrary SQL. See securing the Supabase Studio dashboard for better options, from IP allowlisting to putting it behind a VPN entirely.

Set up backups immediately. This is where Portainer gives you nothing. It manages containers, not data — there's no backup concept, no restore workflow, no offsite copy. A nightly pg_dump cron job is the bare minimum; it still misses Storage files and auth configuration. Supascale's automated backups handle the full picture — database, storage objects, and config — shipped to any S3-compatible target with one-click restore.

Honest Trade-Offs: Portainer vs. Purpose-Built Tooling

Portainer is a fine way to run Supabase if you already live in Portainer. The GUI makes logs, restarts, and resource stats pleasant, and the Repository deploy method gives you reasonable reproducibility. But know what you're signing up for:

  • Updates are manual and risky. New Supabase versions change the compose file and sometimes the volume configs. You have to diff upstream, merge, and redeploy — Portainer's "pull and redeploy" only bumps image tags. Our upgrade guide explains why blind image bumps occasionally eat deployments.
  • No multi-project story. Running a second Supabase instance means duplicating the whole stack with new ports, new secrets, and a new volumes directory — all by hand.
  • Zero Supabase awareness. Portainer can't tell you your JWT is misconfigured, your backups haven't run, or your SSL cert expires Thursday. It sees twelve generic containers.

This is the gap Supascale exists to fill: it deploys and manages self-hosted Supabase specifically — automated S3 backups with restore, custom domains with SSL, OAuth provider configuration, and multi-project management on one server — for a one-time license from $99 rather than a subscription. It coexists fine with Portainer too; plenty of users keep Portainer for their other stacks and let Supascale own the Supabase instances.

Conclusion

Deploying Supabase through Portainer comes down to one insight: the official compose file is not self-contained, so you must stage the volumes/ config directory on the host and convert relative bind mounts to absolute paths before anything will boot. Use the Repository deploy method for reproducibility, generate real JWT secrets, verify the startup cascade, and then close the gaps Portainer doesn't cover — TLS, Studio access, and above all backups. The deploy is one afternoon; the operations are forever, so decide early which of them you want to own by hand.

Further Reading