Building a Self-Hosted Supabase-Like Environment on PostgreSQL
This document details how to use PostgreSQL as a multi-purpose database backend by consolidating the functionality of several systems (MongoDB, Redis, Elasticsearch, etc.) into a single instance. We cover:
- Using PostgreSQL as a document store with
jsonb - Fast, in-memory caching using unlogged tables
- Full-text search with
tsvector - Installing and configuring useful PostgreSQL extensions
- Authentication using
pgcryptoandpgjwt - Implementing Row-Level Security (RLS) with Role-Based Access Control (RBAC)
- Expiring JWT tokens, supporting refresh tokens, and rate limiting
- Exposing your schema via GraphQL with
pg_graphql - Middleware considerations
1. PostgreSQL as a Multi-Purpose Database
1.1 Document Store with jsonb
You can store JSON documents and query them efficiently:
CREATE TABLE users_data (
id SERIAL PRIMARY KEY,
data JSONB
);
INSERT INTO users_data (data)
VALUES ('{"name": "Alice", "age": 30, "skills": ["Go", "Elixir"]}');
SELECT * FROM users_data WHERE data @> '{"name": "Alice"}';1.2 Caching with Unlogged Tables (Poor Man’s Redis)
Unlogged tables avoid the WAL for faster performance (at the cost of durability):
CREATE UNLOGGED TABLE cache (
key TEXT PRIMARY KEY,
value TEXT,
expiry TIMESTAMP
);
INSERT INTO cache (key, value, expiry)
VALUES ('session:123', 'some_data', now() + interval '10 minutes');
SELECT * FROM cache WHERE expiry > now();1.3 Full-Text Search with tsvector (Elasticsearch-like)
PostgreSQL’s full-text search can replace Elasticsearch for many use cases:
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT
);
CREATE INDEX documents_search_idx ON documents USING GIN (to_tsvector('english', content));
SELECT * FROM documents WHERE to_tsvector('english', content) @@ to_tsquery('fast & search');2. PostgreSQL Extensions and Consolidated Setup
2.1 Useful Extensions
- pg_cron: For scheduling tasks.
- pgvector: For vector similarity search.
- pgai: For AI-based search/integration.
- pg_graphql: To expose a GraphQL API directly.
- pgcrypto & pgjwt: For cryptography and JWT support.
- pg_mooncake: For real-time change capture.
- PostgREST: For creating RESTful endpoints.
2.2 Installation Options
You can install extensions via:
- A Bash Script to upgrade an existing PostgreSQL instance.
- A Dockerfile to build a preconfigured image.
Example Bash Script (works on Ubuntu/Debian & Fedora/RHEL)
#!/bin/bash
set -e # Exit on error
PG_VERSION="16"
echo "Installing PostgreSQL $PG_VERSION extensions..."
if [[ -f /etc/debian_version ]]; then
echo "Detected Ubuntu/Debian..."
sudo apt update
sudo apt install -y \
postgresql-$PG_VERSION \
postgresql-contrib \
postgresql-$PG_VERSION-pgvector \
postgresql-$PG_VERSION-pgcrypto \
postgresql-$PG_VERSION-pgjwt \
postgresql-$PG_VERSION-pg_cron \
postgresql-$PG_VERSION-pg_graphql
fi
if [[ -f /etc/redhat-release ]]; then
echo "Detected Fedora/RHEL..."
sudo dnf install -y \
postgresql$PG_VERSION-server \
postgresql$PG_VERSION-contrib \
postgresql$PG_VERSION-pgvector \
postgresql$PG_VERSION-pgcrypto \
postgresql$PG_VERSION-pgjwt \
postgresql$PG_VERSION-pg_cron \
postgresql$PG_VERSION-pg_graphql
fi
echo "Enabling extensions in PostgreSQL..."
sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS pgvector;"
sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;"
sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS pgjwt;"
sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS pg_cron;"
sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS pg_graphql;"
echo "PostgreSQL extensions installed successfully!"Example Dockerfile
FROM postgres:16
# Install necessary tools and build dependencies
RUN apt-get update && apt-get install -y wget gnupg2 build-essential git curl
# Download and store the PGDG key in the keyring directory
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgres.gpg
# Add the PGDG apt repository using the signed-by option
RUN echo "deb [signed-by=/usr/share/keyrings/postgres.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
# Update apt and install required packages
RUN apt-get update && \
apt-get install -y postgresql-16-cron postgresql-contrib-16 && \
rm -rf /var/lib/apt/lists/*
# (Optional) Install additional extensions from source or third-party if needed
# For example, install pgjwt and pg_graphql from source as described previously
# Set up extensions on first run
COPY init-extensions.sql /docker-entrypoint-initdb.d/
EXPOSE 5432Contents of init-extensions.sql:
CREATE EXTENSION IF NOT EXISTS pgvector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pgjwt;
CREATE EXTENSION IF NOT EXISTS pg_cron;
CREATE EXTENSION IF NOT EXISTS pg_graphql;Build and run with:
docker build -t my-postgres .
docker run -d --name postgres -e POSTGRES_PASSWORD=mysecret -p 5432:5432 my-postgres3. Authentication, RBAC, and RLS
3.1 Enabling Authentication Extensions
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pgjwt;3.2 Creating a Users Table with RBAC
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT DEFAULT 'user',
created_at TIMESTAMP DEFAULT now()
);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;3.3 Defining RLS Policies
- User Policy: Regular users see only their own data.
CREATE POLICY user_policy ON users FOR SELECT USING (username = current_setting('jwt.claims.username', true)); - Admin Policy: Admins can bypass and see all rows.
CREATE POLICY admin_policy ON users FOR SELECT USING (current_setting('jwt.claims.role', true) = 'admin'); - Update Policy: Users update only their own rows.
CREATE POLICY user_update_policy ON users FOR UPDATE USING (username = current_setting('jwt.claims.username', true)); - Insert Policy: Default inserts allow only the ‘user’ role.
CREATE POLICY user_insert_policy ON users FOR INSERT WITH CHECK (role = 'user');
3.4 Inserting Sample Users
INSERT INTO users (username, password, role)
VALUES
('admin', crypt('SuperSecurePassword', gen_salt('bf')), 'admin'),
('alice', crypt('alicepassword', gen_salt('bf')), 'user');3.5 JWT Authentication Functions
Authenticate User & Issue Access Token (Expires in 1 Hour)
CREATE OR REPLACE FUNCTION authenticate_user(_username TEXT, _password TEXT)
RETURNS TEXT AS $$
DECLARE
user_record RECORD;
token TEXT;
BEGIN
SELECT id, username, role INTO user_record
FROM users
WHERE username = _username
AND password = crypt(_password, password);
IF NOT FOUND THEN
RETURN NULL;
END IF;
token := jwt.sign(
json_build_object(
'user_id', user_record.id,
'username', user_record.username,
'role', user_record.role,
'exp', extract(epoch from now() + interval '1 hour')
),
'supersecretkey'
);
RETURN token;
END;
$$ LANGUAGE plpgsql;Verify Access Token
CREATE OR REPLACE FUNCTION verify_token(_token TEXT)
RETURNS JSON AS $$
DECLARE
payload JSON;
BEGIN
payload := jwt.verify(_token, 'supersecretkey');
IF (payload->>'exp')::BIGINT < extract(epoch from now()) THEN
RETURN NULL; -- Token expired.
END IF;
RETURN payload;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL; -- Token invalid or error.
END;
$$ LANGUAGE plpgsql;4. Refresh Tokens and Rate Limiting
4.1 Refresh Tokens
Refresh tokens are long-lived (e.g., 7 days) and stored for later exchange.
Create Refresh Tokens Table
CREATE TABLE refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT now()
);Generate a Refresh Token (Expires in 7 Days)
CREATE OR REPLACE FUNCTION generate_refresh_token(_user_id INT, _username TEXT, _role TEXT)
RETURNS TEXT AS $$
DECLARE
rtoken TEXT;
BEGIN
rtoken := jwt.sign(
json_build_object(
'user_id', _user_id,
'username', _username,
'role', _role,
'exp', extract(epoch from now() + interval '7 days')
),
'superrefreshkey'
);
INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES (_user_id, rtoken, now() + interval '7 days');
RETURN rtoken;
END;
$$ LANGUAGE plpgsql;Exchange a Refresh Token for a New Access Token
CREATE OR REPLACE FUNCTION refresh_access_token(_refresh_token TEXT)
RETURNS TEXT AS $$
DECLARE
payload JSON;
new_token TEXT;
BEGIN
payload := jwt.verify(_refresh_token, 'superrefreshkey');
IF (payload->>'exp')::BIGINT < extract(epoch from now()) THEN
RETURN NULL; -- Refresh token expired.
END IF;
IF NOT EXISTS (SELECT 1 FROM refresh_tokens WHERE token = _refresh_token) THEN
RETURN NULL;
END IF;
new_token := jwt.sign(
json_build_object(
'user_id', payload->>'user_id',
'username', payload->>'username',
'role', payload->>'role',
'exp', extract(epoch from now() + interval '1 hour')
),
'supersecretkey'
);
RETURN new_token;
END;
$$ LANGUAGE plpgsql;4.2 Rate Limiting (Fixed Window per Minute)
A simple rate limiter tracks the number of requests per user per minute.
Create Rate Limits Table
CREATE TABLE rate_limits (
user_id INT,
period_start TIMESTAMP NOT NULL,
count INT DEFAULT 0,
PRIMARY KEY (user_id, period_start)
);Check and Update Rate Limit (Allow 60 Requests/Minute)
CREATE OR REPLACE FUNCTION check_rate_limit(_user_id INT)
RETURNS BOOLEAN AS $$
DECLARE
current_period TIMESTAMP := date_trunc('minute', now());
current_count INT;
limit CONSTANT INT := 60;
BEGIN
INSERT INTO rate_limits (user_id, period_start, count)
VALUES (_user_id, current_period, 1)
ON CONFLICT (user_id, period_start)
DO UPDATE SET count = rate_limits.count + 1
RETURNING count INTO current_count;
IF current_count > limit THEN
RETURN FALSE; -- Rate limit exceeded.
END IF;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;Clean Up Old Rate Limits with pg_cron
SELECT cron.schedule(
'cleanup_rate_limits',
'0 * * * *',
$$DELETE FROM rate_limits WHERE period_start < now() - interval '1 hour'$$
);5. Exposing Your Schema via GraphQL with pg_graphql
5.1 Installing and Enabling pg_graphql
First, ensure the extension is available and enable it:
CREATE EXTENSION IF NOT EXISTS pg_graphql;5.2 Using pg_graphql
The extension auto-generates a GraphQL schema from your tables. For example, if you have a posts table, you can run:
SELECT graphql('{ posts { id, title, content } }');The GraphQL query returns data in JSON format.
5.3 Integration with RLS and RBAC
- Session Variables:
Before executing GraphQL queries, set session variables with JWT claims:SET LOCAL jwt.claims.username = 'alice'; SET LOCAL jwt.claims.role = 'user'; - RLS Policies Apply:
The same RLS policies you defined on your tables (e.g., restricting users to only see their own posts) will automatically filter the data returned by GraphQL.
6. Middleware Considerations
The middleware that:
- Extracts the JWT from HTTP requests,
- Verifies the token,
- Sets session parameters on the database connection
Should reside in your application layer.
While you can write stored procedures in PostgreSQL for some logic, handling HTTP, token extraction, and session management is best done in your app (e.g., Node.js, Python, Go, etc.). This approach ensures your database stays focused on enforcing RLS and RBAC while the app handles request routing and authentication.
7. Summary & Next Steps
This guide demonstrates how to build a self-hosted, Supabase-like environment on PostgreSQL that includes:
- Multi-purpose data storage: Using JSONB, unlogged tables, and full-text search.
- Extensions: Installing pg_cron, pgvector, pg_graphql, pgcrypto, pgjwt, etc.
- Authentication: Secure user authentication with password hashing and JWTs.
- Authorization: Combining RBAC with Row-Level Security to control data access.
- Token Management: Issuing short-lived access tokens with refresh tokens for renewal.
- Rate Limiting: Protecting your API endpoints with a fixed-window rate limiter.
- GraphQL API: Exposing your database schema securely via pg_graphql.
- Middleware: Implementing the necessary middleware in your application layer.
Feel free to adjust the expiration times, request limits, and policies to best fit your needs. This setup provides a robust foundation for a unified, secure, and scalable backend that replaces multiple separate systems with a single PostgreSQL instance.
Why always me?