Supabase has become the default open-source alternative to Firebase, offering PostgreSQL as a database, built-in authentication, instant REST and GraphQL APIs, realtime subscriptions, edge functions, and file storage — all behind a single dashboard. The managed Supabase Cloud service works well for prototyping, but production workloads hit free tier limits quickly: 500MB database, 1GB file storage, 2GB bandwidth, and 50,000 monthly active users. Beyond those limits, pricing scales with usage in ways that are difficult to predict. Self-hosting Supabase on your own Ubuntu VPS eliminates every one of those constraints while giving you full control over your data.

Running Supabase yourself means no vendor lock-in, complete data sovereignty, predictable monthly costs regardless of traffic, and the ability to customize every component. The trade-off is operational responsibility — you manage updates, backups, and scaling. This guide walks through the entire process: from understanding the architecture to deploying a production-ready Supabase instance with SSL, proper security, and backup automation.

MassiveGRID Ubuntu VPS includes: Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10

Deploy a self-managed VPS — from $1.99/mo
Need dedicated resources? — from $19.80/mo
Want fully managed hosting? — we handle everything

Why Self-Host Supabase

The case for self-hosting goes beyond cost savings. Here are the concrete advantages:

Supabase Cloud vs Self-Hosted: An Honest Comparison

Self-hosting is not universally better. Here is what you gain and what you lose:

What you gain: Unlimited database size, unlimited bandwidth, unlimited active users, full PostgreSQL superuser access, custom extensions, data residency control, no per-project pricing, and the ability to run multiple projects on one instance.

What you lose: Automatic updates, managed backups, the Supabase dashboard's project management features, built-in log drains, branching (preview environments), edge function deployment infrastructure, and official support. You also lose the convenience of one-click setup — self-hosting requires understanding Docker, networking, and PostgreSQL administration.

The general rule: use Supabase Cloud for prototypes and small projects. Self-host when you need predictable pricing at scale, data sovereignty, or deep PostgreSQL customization.

Understanding Supabase Architecture

Supabase is not a single application. It is a composition of 10+ services, each handling a specific concern. Understanding what each service does helps you troubleshoot issues and tune performance:

In Docker, each service runs as its own container. This is why Supabase needs more RAM than a typical application — you are running an entire backend-as-a-service platform.

Prerequisites

Supabase runs 10+ Docker containers simultaneously. Minimum requirements for a development or staging environment: 4 vCPU and 8GB RAM. For production workloads, allocate 4 vCPU and 16GB RAM with room to scale. A Cloud VPS with independent resource scaling lets you add RAM without migrating.

You need Docker and Docker Compose installed. If you have not set those up yet, follow our Docker installation guide for Ubuntu VPS first.

Verify your Docker installation:

docker --version
docker compose version

You also need a domain name pointed to your server's IP address, and Git installed to clone the Supabase repository:

sudo apt update
sudo apt install -y git

Docker Compose Setup

Supabase provides an official self-hosting configuration. Clone the repository and use the Docker directory:

# Clone the Supabase repository
git clone --depth 1 https://github.com/supabase/supabase.git /opt/supabase
cd /opt/supabase/docker

# Copy the example environment file
cp .env.example .env

The docker directory contains the docker-compose.yml and configuration files for all services. Before starting anything, you must configure the environment variables — running with defaults is a security risk.

Environment Configuration

The .env file controls every aspect of your Supabase deployment. Open it and configure these critical variables:

# /opt/supabase/docker/.env

# ── Secrets (CHANGE ALL OF THESE) ──────────────────────────────
# Generate each with: openssl rand -base64 32

POSTGRES_PASSWORD=your-super-secret-postgres-password
JWT_SECRET=your-super-secret-jwt-token-at-least-32-characters
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=your-dashboard-password

# ── API Configuration ──────────────────────────────────────────
SITE_URL=https://supabase.yourdomain.com
API_EXTERNAL_URL=https://supabase.yourdomain.com
SUPABASE_PUBLIC_URL=https://supabase.yourdomain.com

# ── Database ───────────────────────────────────────────────────
POSTGRES_HOST=db
POSTGRES_DB=postgres
POSTGRES_PORT=5432

# ── Studio ─────────────────────────────────────────────────────
STUDIO_DEFAULT_ORGANIZATION=My Organization
STUDIO_DEFAULT_PROJECT=My Project
STUDIO_PORT=3000

# ── Auth (GoTrue) ─────────────────────────────────────────────
GOTRUE_SITE_URL=https://yourdomain.com
GOTRUE_EXTERNAL_EMAIL_ENABLED=true
GOTRUE_MAILER_AUTOCONFIRM=false
GOTRUE_SMTP_HOST=smtp.yourdomain.com
GOTRUE_SMTP_PORT=587
GOTRUE_SMTP_USER=noreply@yourdomain.com
GOTRUE_SMTP_PASS=your-smtp-password
GOTRUE_SMTP_SENDER_NAME=Your App

# ── Storage ────────────────────────────────────────────────────
STORAGE_BACKEND=file
FILE_SIZE_LIMIT=52428800  # 50MB

Generate the JWT keys using the Supabase CLI or the JWT generation tool. The ANON_KEY is a JWT token with the anon role, and the SERVICE_ROLE_KEY is a JWT with the service_role role. Both must be signed with your JWT_SECRET:

# Generate JWT tokens (requires Node.js or use an online tool)
# Payload for ANON_KEY:
# { "role": "anon", "iss": "supabase", "iat": 1735689600, "exp": 1893456000 }

# Payload for SERVICE_ROLE_KEY:
# { "role": "service_role", "iss": "supabase", "iat": 1735689600, "exp": 1893456000 }

# Generate with openssl:
JWT_SECRET=$(openssl rand -base64 32)
echo "JWT_SECRET: $JWT_SECRET"

Now start the stack:

cd /opt/supabase/docker
docker compose up -d

Watch the logs during initial startup to verify all services come up healthy:

docker compose logs -f --tail=50

The first start takes 2-5 minutes as containers pull images and PostgreSQL initializes. Check service health:

docker compose ps

All services should show as "healthy" or "running."

Nginx Reverse Proxy with SSL

Supabase's Kong API gateway listens on port 8000 by default, and Studio on port 3000. You need a reverse proxy with SSL termination to expose these securely. If you do not already have Nginx configured, follow our Nginx reverse proxy guide and SSL certificate installation guide.

# /etc/nginx/sites-available/supabase
server {
    listen 80;
    server_name supabase.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name supabase.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/supabase.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/supabase.yourdomain.com/privkey.pem;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Kong API Gateway — handles all API routes
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support for Realtime
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400;
    }
}

# Studio dashboard (optional — restrict access)
server {
    listen 443 ssl http2;
    server_name studio.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/studio.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/studio.yourdomain.com/privkey.pem;

    # Restrict to your IP
    allow 203.0.113.50;
    deny all;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
sudo ln -s /etc/nginx/sites-available/supabase /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

The WebSocket upgrade headers are critical — without them, Supabase Realtime will not work. The proxy_read_timeout 86400 keeps WebSocket connections alive for up to 24 hours.

First Login to Studio Dashboard

Navigate to your Studio URL (e.g., https://studio.yourdomain.com). You will be prompted for the credentials you set in DASHBOARD_USERNAME and DASHBOARD_PASSWORD.

Once logged in, Studio shows your project overview with sections for the Table Editor, SQL Editor, Authentication, Storage, and Edge Functions. The Table Editor provides a spreadsheet-like interface for viewing and editing database records. The SQL Editor lets you run raw queries and save them as snippets.

Verify the connection is working by opening the SQL Editor and running:

SELECT version();
SELECT current_database();
SELECT * FROM pg_extension;

You should see PostgreSQL 15+ with extensions like uuid-ossp, pgcrypto, and pgjwt already installed.

Creating Your First Project

Unlike Supabase Cloud, self-hosted Supabase does not have multi-project support by default — you get one PostgreSQL database. Structure your application using schemas:

-- Create a table in the public schema
CREATE TABLE public.profiles (
    id UUID REFERENCES auth.users(id) PRIMARY KEY,
    username TEXT UNIQUE NOT NULL,
    avatar_url TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Enable Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;

-- Policy: users can read all profiles
CREATE POLICY "Profiles are viewable by everyone"
    ON public.profiles
    FOR SELECT
    USING (true);

-- Policy: users can update their own profile
CREATE POLICY "Users can update own profile"
    ON public.profiles
    FOR UPDATE
    USING (auth.uid() = id);

-- Enable realtime for this table
ALTER PUBLICATION supabase_realtime ADD TABLE public.profiles;

For file storage, create a bucket via the Studio dashboard or the Storage API:

-- Create a storage bucket for avatars
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);

-- Policy: authenticated users can upload
CREATE POLICY "Avatar upload"
    ON storage.objects
    FOR INSERT
    WITH CHECK (bucket_id = 'avatars' AND auth.role() = 'authenticated');

Connecting Your Application

Install the Supabase JavaScript SDK in your application:

npm install @supabase/supabase-js

Initialize the client pointing to your self-hosted instance:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
    'https://supabase.yourdomain.com',
    'your-anon-key'  // The ANON_KEY from your .env
)

// Sign up a user
const { data, error } = await supabase.auth.signUp({
    email: 'user@example.com',
    password: 'secure-password'
})

// Query data
const { data: profiles } = await supabase
    .from('profiles')
    .select('*')
    .limit(10)

// Subscribe to realtime changes
const channel = supabase
    .channel('profiles-changes')
    .on('postgres_changes',
        { event: '*', schema: 'public', table: 'profiles' },
        (payload) => console.log('Change:', payload)
    )
    .subscribe()

You can also use the REST API directly without the SDK:

# Query profiles via REST
curl -X GET 'https://supabase.yourdomain.com/rest/v1/profiles?select=*' \
    -H "apikey: YOUR_ANON_KEY" \
    -H "Authorization: Bearer YOUR_ANON_KEY"

# Insert a record
curl -X POST 'https://supabase.yourdomain.com/rest/v1/profiles' \
    -H "apikey: YOUR_ANON_KEY" \
    -H "Authorization: Bearer YOUR_USER_JWT" \
    -H "Content-Type: application/json" \
    -d '{"username": "johndoe", "avatar_url": "https://example.com/avatar.jpg"}'

Securing Your Deployment

A self-hosted Supabase instance requires deliberate security hardening. The defaults are designed for development, not production:

Rotate default secrets immediately:

# Generate new secrets
openssl rand -base64 32  # For JWT_SECRET
openssl rand -base64 24  # For POSTGRES_PASSWORD
openssl rand -base64 24  # For DASHBOARD_PASSWORD

Restrict network access:

# Only expose ports through Nginx — block direct access
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 8000    # Kong
sudo ufw deny 3000    # Studio
sudo ufw deny 5432    # PostgreSQL
sudo ufw enable

Configure Docker to not bypass UFW:

# /etc/docker/daemon.json
{
    "iptables": false
}

# Restart Docker after this change
sudo systemctl restart docker

Disable public sign-ups if your application handles registration differently:

# In .env
GOTRUE_DISABLE_SIGNUP=true

Set rate limits in Kong: Edit the Kong configuration in volumes/api/kong.yml to add rate limiting plugins to your routes, preventing API abuse.

For comprehensive server hardening beyond Supabase, follow our Ubuntu VPS security hardening guide.

PostgreSQL Tuning for Supabase Workloads

The default PostgreSQL configuration inside the Supabase Docker container is conservative. For production workloads, mount a custom configuration. For detailed PostgreSQL optimization, see our PostgreSQL guide.

Create a custom PostgreSQL config that the Docker container will use:

# /opt/supabase/docker/volumes/db/postgresql.conf (appended settings)

# Memory — allocate 25% of total RAM to shared_buffers
shared_buffers = 4GB
effective_cache_size = 12GB
work_mem = 64MB
maintenance_work_mem = 512MB

# WAL — important for Realtime service
wal_level = logical
max_wal_senders = 10
max_replication_slots = 10

# Connections — PostgREST and other services use connection pooling
max_connections = 200

# Query planner
random_page_cost = 1.1          # NVMe storage
effective_io_concurrency = 200  # NVMe storage
default_statistics_target = 100

After modifying the configuration, restart the database container:

cd /opt/supabase/docker
docker compose restart db

Monitor query performance from Studio's SQL Editor:

-- Enable pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Find slow queries
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

Backup Strategy

Self-hosting means you own backups. Your Supabase instance has two categories of data to protect: the PostgreSQL database and the file storage objects.

Database backups with pg_dump:

#!/bin/bash
# /opt/supabase/backup.sh

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/supabase/backups"
mkdir -p "$BACKUP_DIR"

# Dump the entire database
docker compose -f /opt/supabase/docker/docker-compose.yml exec -T db \
    pg_dump -U postgres -Fc --no-owner postgres \
    > "$BACKUP_DIR/supabase_db_$TIMESTAMP.dump"

# Backup storage objects
tar -czf "$BACKUP_DIR/supabase_storage_$TIMESTAMP.tar.gz" \
    /opt/supabase/docker/volumes/storage/

# Remove backups older than 30 days
find "$BACKUP_DIR" -name "*.dump" -mtime +30 -delete
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +30 -delete

echo "Backup completed: $TIMESTAMP"
# Make executable and schedule
chmod +x /opt/supabase/backup.sh

# Run daily at 3 AM
crontab -e
# Add: 0 3 * * * /opt/supabase/backup.sh >> /var/log/supabase-backup.log 2>&1

For automated off-server backup strategies including encryption and remote storage, see our backup automation guide.

Restore from backup:

# Restore database
docker compose -f /opt/supabase/docker/docker-compose.yml exec -T db \
    pg_restore -U postgres -d postgres --clean --no-owner \
    < /opt/supabase/backups/supabase_db_20260228_030000.dump

Resource Monitoring and Scaling

Different Supabase services consume resources differently. Understanding the profile helps you scale the right component:

Monitor overall resource usage:

# Container-level resource usage
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}"

# Check PostgreSQL connections
docker compose exec db psql -U postgres -c "SELECT count(*) FROM pg_stat_activity;"

# Check disk usage
du -sh /opt/supabase/docker/volumes/*/

Your entire backend runs on one server. Dedicated VPS resources ensure consistent performance across all Supabase services — no noisy neighbors affecting database query latency or realtime WebSocket throughput.

Prefer Managed Supabase Hosting?

Self-hosted Supabase involves managing PostgreSQL, GoTrue, PostgREST, Realtime, Storage, Kong, and Studio — each with its own update cadence, configuration requirements, and failure modes. PostgreSQL alone requires monitoring replication slots, WAL size, connection counts, and query performance. Multiply that across every service in the stack.

MassiveGRID's fully managed hosting handles the infrastructure layer: server administration, security patches, performance tuning, backup verification, and 24/7 monitoring. You deploy and configure Supabase. We keep the server running, secure, and optimized. That division of responsibility gives you the benefits of self-hosting without the undifferentiated heavy lifting of server management.