Zero to Production: Part 2 - Taming the Elephant: Self-Hosting Supabase
In the last post, we deployed an Oracle VPS and self hosted Coolify aka our control plane. Next up we are going to deploy our data layer, so that we can self host all our storage and that means deploying Supabase through Coolify.
While Coolify provides a seamless installation experience, moving a massive microservice architecture like Supabase off a managed cloud tier and onto our own hardware reveals a lot of its underlying complexity.
This is how I spun up self-hosted Supabase, customized its routing, prepared it for the Powersync engine, and fixed a couple of critical security vulnerabilities along the way.
Phase 1: Deploying the Template
Supabase isn’t just a database; it is an ecosystem of a dozen interacting open-source microservices including PostgreSQL, Kong (the API gateway), GoTrue (Auth), Realtime, Storage, and the Studio UI. Managing this manually via Docker Compose can be an absolute headache, but Coolify turns it into an elegant configuration process.
Step 1: Spin up the Service
- Log into your Coolify dashboard
https://coolify.<domain.com>. - Navigate to your project workspace and click the + New button to add a resource.
- Select Service from the options, search for Supabase, and select it from the template catalog.
Step 2: Configure Secrets and Domains
Before hitting deploy, Coolify presents a configuration screen.
- Passwords & Secrets: Coolify automatically generates secure PostgreSQL passwords, JWT secrets, and API keys. Leave these as auto-generated, but copy and save the
POSTGRES_PASSWORDandJWT_SECRETimmediately into a secure password manager. Powersync will require these credentials later to connect. Also copy theDASHBOARD_USERandDASHBOARD_PASSWORDas you will need these to login to the Supabase Studio to configure your instance. - Domains: Because we configured a wildcard domain (
https://*.<domain.com>) in Part 1, Coolify automatically generates subdomains and maps the primary routing rules for the Supabase Studio UI and API gateway.
Click Save and then Deploy.
Step 3: Monitoring the Initial Boot
Because Supabase pulls down and unpacks around twelve distinct Docker containers, the initial build takes a few minutes even on a high-spec ARM Ampere server. Keep an eye on the deployment logs.
Once every container turns green, you are officially self-hosting Supabase.
Phase 2: Solving the Port 8000 Quirk & Clean Domains
When my instance initially deployed, Coolify mapped the Supabase Kong gateway to a randomly generated URL appended with an explicit port number: https://supabasekong-[id].<domain.com>:8000.
If you try to remove that :8000 right out of the box, Traefik (Coolify's internal reverse proxy) will fail to route traffic to the container, throwing a 502 Bad Gateway error.
The Under-the-Hood Reality
This is a standard Coolify UI quirk. Appending :8000 to the domain configuration box is not telling the public internet to open or use port 8000. Instead, it is an internal instruction telling Traefik: "When a user visits this URL on the standard, secure public HTTPS port (443), grab that traffic and route it to this specific Docker container’s internal port 8000."
Since Kong listens internally on port 8000, Traefik needs this mapping. However, leaving a long, random string with an exposed port layout is messy.
The Fix: Custom URLs and Environment Syncing
Thanks to the wildcard DNS record we set up in Part 1, you can change this URL instantly to something like https://supabase.<domain.com> which is clean and production-ready without touching your DNS registrar.
- Update the Container FQDN: In the Coolify dashboard, navigate to your Supabase service stack, scroll down to the Supabase Kong container settings, find the Domains field, and replace the random string with your clean domain:
https://supabase.<domain.com>. - Update Environment Variables (Crucial Step): While Traefik needs to know how to route the traffic, Supabase itself needs to know its new public identity. If it doesn't, it will generate broken authentication links and password-reset emails that append random strings or ports. Go to the Environment Variables tab in your Supabase service and update both
API_EXTERNAL_URLandSTUDIO_URL(orSTUDIO_EXTERNAL_URL) to your clean URL:https://supabase.<domain.com>. - Redeploy: Click Restart / Redeploy. Traefik will reach out to Let's Encrypt, secure a fresh SSL certificate for your clean domain, and seamlessly route all port 443 public traffic to Kong's internal port 8000.
Phase 3: Tweaking Postgres for Real-Time Sync
An application using an offline-first architecture relies on streaming data changes instantly from the central server down to the client devices. PowerSync executes this by reading the PostgreSQL Write-Ahead Log (WAL)—a continuous append-only record of every single database modification.
To enable this real-time stream, the Postgres instance must have its replication level explicitly configured.
Step 1: Verify the Database WAL Level
The self-hosted Supabase Postgres image (supabase/postgres) typically defaults to the correct setting because Supabase's native engine utilizes it, but you should always explicitly verify it before connecting any external sync engines.
Open your Supabase Studio via the new URL, use the auto generated dashboard user name and password to login to the interface, navigate to the SQL Editor, paste the following query, and run it:
SHOW wal_level;- If it returns
logical: The database engine is fully prepared to stream data changes. - If it returns
replicaorminimal: Go back to your Supabase database configurations inside Coolify, append-c wal_level=logicalto your Postgres start command inside the compose file, and restart the service.
Step 2: Creating the PowerSync Database Infrastructure
PowerSync requires a dedicated database user with high-level replication privileges, an isolated tracking schema, and a global database publication to monitor database tables.
Run this consolidated script within the Supabase SQL Editor to prepare your environment:
-- 1. Create a dedicated database user for the PowerSync sync engine
CREATE USER powersync_role WITH PASSWORD 'YourSecurePasswordHere' REPLICATION;
-- 2. Grant the new role permissions to the standard public schema
GRANT USAGE ON SCHEMA public TO powersync_role;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role;
-- 3. Create a dedicated schema where PowerSync maintains its internal sync state
CREATE SCHEMA powersync;
GRANT ALL PRIVILEGES ON SCHEMA powersync TO powersync_role;
-- 4. Create the publication that defines what data gets replicated
-- Note: 'FOR ALL TABLES' tracks everything; you can restrict this scope later if needed
CREATE PUBLICATION powersync_publication FOR ALL TABLES;Phase 4: Two Critical Real-World "Gotchas"
When you move away from managed platforms, you quickly learn to read Docker container health logs deeply. Two distinct behaviors caught my attention during this stage.
1. The Mysteriously "Exited" MinIO Client Container
Looking closely at the Docker container list inside Coolify, I noticed that a container named minio/mc (the MinIO Client) consistently showed a status of Exited. Naturally, my engineering instincts flag an exited container as a crash or deployment failure.
The reality is that in a complex microservice stack, containers generally fall into two categories: continuous services (like the database, which must run indefinitely) and initialization tasks.
The minio/mc container is a short-lived task runner. Its only job during system boot is to wait for the main object storage engine to become healthy, log in using your root administrative credentials, programmatically create the default Supabase storage buckets, and set their public access policies. Once those commands execute successfully, it has completed its lifecycle and gracefully shuts down.
To verify a perfect execution, look at the exit code and the container logs:
- Exit Code
0: The task executed successfully with zero errors. - Logs Check: Clicking the container logs should display:
Bucket created successfully 'storage/supabase-bucket'.
2. The Missing Observability Tab
If you look for the native "Observability” dashboard inside the self-hosted Supabase Studio UI, you will find it completely missing and this is intentional.
On the Supabase cloud platform, those beautiful analytics charts are powered by a massive, proprietary log-processing infrastructure (BigQuery and Logflare) that requires far too much memory and overhead to bundle into a lightweight self-hosted package. Trying to run that on a single VPS would chew through your RAM instantly.
Standard production practice is to monitor your stack via an external dockerized observability layer. We will be deploying Prometheus and Grafana directly inside Coolify in a later part of this series to handle this natively without destroying our server performance.
Phase 5: Hardening the Security Perimeter
Shortly after establishing database migrations from my local machine, I opened the Supabase Postgres logs inside Coolify and saw an alarming wave of connection failures:
FATAL: password authentication failed for user "postgres" from IP 85.11.167.7
ERROR: unsupported frontend protocol 0.0 from IP 199.45.154.141The Diagnosis: Active Botnet Attacks
This is the reality of exposing infrastructure to the public internet. Within hours of any server going live, automated internet-wide scanners locate open ports.
- The first IP (
85.11.167.7) belongs to a known malicious botnet actively sweeping for exposed Postgres instances to try and guess default passwords to deploy cryptocurrency miners. - The second IP (
199.45.154.141) belongs to Censys, a global research crawler that sends generic network probes to open ports to identify running software.
This happened because I opened the standard Postgres port 5432 to allow database migrations from my local machine, but set the Oracle VCN Ingress source rule to 0.0.0.0/0 (the entire world).
The Immediate Fix: Scoping the Firewall
Do not let Docker's internal networking fool you; it can bypass standard localized Linux firewalls, making your cloud provider's network security groups your main line of defense, which means the ufw rules we setup earlier are being overridden by our Docker containers.
- Find your exact public IP address.
- Log into Oracle Cloud Infrastructure Console.
- Navigate to Networking -> Virtual Cloud Networks, find the VCN, and open its Security Lists.
- Locate the Ingress Rule handling TCP port
5432. - Edit the Source CIDR and change it from
0.0.0.0/0to your precise local IP, forcing a strict/32subnet mask (e.g.,203.0.113.45/32).
Once that rule is saved, the botnets are blocked at the edge of Oracle's network infrastructure long before they can hit the database port. My local machine, however, retains perfect migration access. This same methodology can be applied to any other VPS providers security configuration as well.
With the core database completely locked down, optimally tuned, and verified for logical replication, our backend foundation is solid. Next up, we deploy the real-time sync layer via Powersync in Part 3 - The Sync Layer: PowerSync in the Wild