Skip to content

Instantly share code, notes, and snippets.

@micah1701
Last active August 29, 2026 16:16
Show Gist options
  • Select an option

  • Save micah1701/18a98caf754e4547ccd960683056ee2d to your computer and use it in GitHub Desktop.

Select an option

Save micah1701/18a98caf754e4547ccd960683056ee2d to your computer and use it in GitHub Desktop.
Custom Domain Proxy for Supabase

πŸŒ€ Supabase Reverse Proxy (Apache)

Put your own domain in front of a Supabase project instead of shipping *.supabase.co URLs to your users without paying for Supabase's custom domain add-on.

This started as a quick Apache vhost and turned into a small case study in how many ways CORS can quietly make you waste half a day.


πŸš€ Why This Exists

Supabase gives every project a public API at https://YOUR_PROJECT_ID.supabase.co. Totally fine for a side project, less fine when you want:

  • βœ… A professional-looking API domain (api.yourbrand.com, not xyzabc123.supabase.co)
  • βœ… No recurring per-project custom-domain fee
  • βœ… Full control over routing, headers, and CORS at the edge

My Creative Ad-Hoc Solution? A lightweight Apache reverse proxy on a box you already run, forwarding to Supabase behind the scenes. Your frontend only ever talks to your domain.


βš™οΈ How It Works

Supabase isn't one service, it's several services all reachable under one project URL:

Path Service What it's for
/auth/v1/* Auth (GoTrue) login, signup, token refresh, logout
/rest/v1/* REST (PostgREST) supabase.from('table') queries
/storage/v1/* Storage file upload / download
/realtime/v1/* Realtime (WebSocket) live subscriptions
/functions/v1/* Edge Functions your own custom serverless code

The proxy's job: match each prefix, forward to the same path upstream, attach the right headers, and set CORS correctly on the way back out. That last part is where all the time went.


πŸ”₯ How This Config Evolved

❌ v1: CORS Headers Only on Preflight

<If "%{REQUEST_METHOD} == 'OPTIONS'">
    Header always set Access-Control-Allow-Origin "*"
</If>

Initially, this worked fine when calling Supabase functions because my edge functions set their own headers and we only needed to add -Allow-Origin on first ping from the browser (which doesn't return the actual edge function so it wouldn't include those custom headers.)

When I started using the proxy for other services, like /auth, preflight would pass but the real request still failed. Fun fact, browsers require Access-Control-Allow-Origin on the actual response too, not just the OPTIONS check. Fix: drop the conditional, set it on every response.


❌ v2: Two Servers, Two Opinions

That fixed the missing CORS header for /auth but then broke everything else. Not only did many of my edge functions set their own Access-Control-Allow-Origin but Supabase's own gateway (Kong) sets it too. So having Apache also set those headers mucked everything up. Turns out, misconfigured (or doubled) CORS headers cause browsers to reject them outright.

# βœ… strip whatever came from upstream, then set exactly one
Header unset Access-Control-Allow-Origin
Header always set Access-Control-Allow-Origin "*"

❌ v3: A Doubled Path Segment

CORS errors are a red herring. You make one silly mistake and get back an irrelevent error sending you on a wild goose chase.

RewriteRule ^/auth/(.*)$ https://YOUR_PROJECT_ID.supabase.co/auth/v1/$1 [P,L]

The incoming path was already /auth/v1/token and this rule bolted on a second v1, producing .../auth/v1/v1/token β†’ 404. And a 404 has no CORS headers, so the browser reported it as a CORS error instead of a routing bug.

# βœ… fixed: no extra v1
RewriteRule ^/auth/(.*)$ https://YOUR_PROJECT_ID.supabase.co/auth/$1 [P,L]

🧠 Lesson: a CORS error on a proxy is often a routing error wearing a disguise. Check the actual proxied URL first.


❌ v4: A Hardcoded Header Allow-List

Header always set Access-Control-Allow-Headers "authorization, apikey, content-type"

Worked, until a client library update sent one more header (x-supabase-api-version) that wasn't on the list. Rather than patch this file every time a client adds a header, we reflect back whatever the browser actually asked for:

# βœ… capture what the browser is requesting permission for...
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteCond %{REQUEST_URI} ^/auth/
RewriteRule ^ - [E=ACRH:%{HTTP:Access-Control-Request-Headers},R=204,L]

# ...and echo it straight back
Header always set Access-Control-Allow-Headers "%{ACRH}e" env=ACRH

Future-proof, zero maintenance.


❌ v5: REST Isn't Functions

I started the proxy to access my edge functions. Then realized the /auth service worked differently and needed its own explicit route. This, again, worked great until I wanted to use the Supabase /rest endpoint to make actual database queries.

Initially, only /auth/* had an explicit route and everything else fell into the catch-all pointed at /functions/v1/. So hitting /rest/v1 meant every table query proxied to a nonexistent function path and 404'd (see v3's lesson above: never trust a CORS error as the real problem).

# βœ… give REST its own explicit route, same shape as auth
RewriteCond %{REQUEST_URI} ^/rest/(.*)$
RewriteRule ^/rest/(.*)$ https://YOUR_PROJECT_ID.supabase.co/rest/$1 [P,L]
ProxyPassReverse /rest/ https://YOUR_PROJECT_ID.supabase.co/rest/

Storage and Realtime got the same treatment ahead of time, before we actually needed them.


❌ v6: It's Not You, Its Me

Another silly mistake and lesson learned: One endpoint kept failing preflight no matter what we changed. Instead of guessing from the browser console, we asked the server directly:

curl -sS -D - -o /dev/null -X OPTIONS "https://YOUR_DOMAIN/auth/v1/logout" \
  -H "Origin: https://yourfrontend.com" \
  -H "Access-Control-Request-Method: POST"

Response: a generic 200 OK, no CORS headers, identical across three completely different routes. That's not something our config would ever produce, meaning our config wasn't the one running. Turns out the file on the server had a typo'd filename, and a prior edit had dropped SSLCertificateKeyFile, so Apache was quietly still serving the old vhost.

🧠 Lesson: when behavior doesn't match the config in front of you, stop editing and go verify it's actually the config that's live. curl bypasses the browser's abstracted errors and shows you the truth in one shot.


🧠 Why the Final Version Works

  • βœ… CORS headers set on every response, not just preflight
  • βœ… One route per Supabase service. No catch-all swallowing a real endpoint
  • βœ… Allowed headers reflected dynamically instead of hardcoded
  • βœ… Exactly one Access-Control-Allow-Origin, ours, always
  • βœ… apikey only injected where Supabase's gateway actually requires it (auth/rest/storage). Edge Functions verify the JWT themselves

πŸ› οΈ Setup

1. Required Apache modules

sudo a2enmod rewrite headers proxy proxy_http proxy_wstunnel ssl setenvif

2. Drop in the vhost

sudo THIS_VHOST_FILE.conf /etc/apache2/sites-available/YOUR_DOMAIN.conf
sudo a2ensite YOUR_DOMAIN.conf

3. Test config before reloading

sudo apache2ctl configtest

4. Reload

sudo systemctl reload apache2

▢️ Final Config

<VirtualHost *:443>
    ServerName YOUR_DOMAIN

    Include /etc/letsencrypt/options-ssl-apache.conf
    SSLProxyEngine On

    RewriteEngine On

    SetEnvIf Request_URI "^/(auth|rest|storage)/" IS_SUPABASE_GATEWAY=1

    RewriteCond %{REQUEST_METHOD} OPTIONS
    RewriteCond %{REQUEST_URI} ^/(auth|rest|storage)/
    RewriteRule ^ - [E=ACRH:%{HTTP:Access-Control-Request-Headers},R=204,L]

    Header unset Access-Control-Allow-Origin
    Header always set Access-Control-Allow-Origin "*"
    Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" env=ACRH
    Header always set Access-Control-Allow-Headers "%{ACRH}e" env=ACRH
    Header always set Access-Control-Max-Age "3600" env=ACRH

    SetEnvIf Authorization "^(.*)" PROXY_AUTH=$1

    ProxyPreserveHost Off
    RequestHeader set Host "YOUR_PROJECT_ID.supabase.co"
    RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
    RequestHeader set X-Forwarded-Proto https
    RequestHeader set apikey YOUR_SUPABASE_PUBLISHABLE_KEY env=IS_SUPABASE_GATEWAY
    RequestHeader set Authorization "%{PROXY_AUTH}e" env=PROXY_AUTH

    RewriteCond %{REQUEST_URI} ^/auth/(.*)$
    RewriteRule ^/auth/(.*)$ https://YOUR_PROJECT_ID.supabase.co/auth/$1 [P,L]
    ProxyPassReverse /auth/ https://YOUR_PROJECT_ID.supabase.co/auth/

    RewriteCond %{REQUEST_URI} ^/rest/(.*)$
    RewriteRule ^/rest/(.*)$ https://YOUR_PROJECT_ID.supabase.co/rest/$1 [P,L]
    ProxyPassReverse /rest/ https://YOUR_PROJECT_ID.supabase.co/rest/

    RewriteCond %{REQUEST_URI} ^/storage/(.*)$
    RewriteRule ^/storage/(.*)$ https://YOUR_PROJECT_ID.supabase.co/storage/$1 [P,L]
    ProxyPassReverse /storage/ https://YOUR_PROJECT_ID.supabase.co/storage/

    RewriteCond %{HTTP:Upgrade} =websocket [NC]
    RewriteCond %{REQUEST_URI} ^/realtime/(.*)$
    RewriteRule ^/realtime/(.*)$ wss://YOUR_PROJECT_ID.supabase.co/realtime/$1 [P,L]

    ProxyPass        / https://YOUR_PROJECT_ID.supabase.co/functions/v1/
    ProxyPassReverse / https://YOUR_PROJECT_ID.supabase.co/functions/v1/

    SSLCertificateFile /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem
</VirtualHost>

⚠️ Important Notes

  • Test preflight directly with curl or Postman before trusting the browser console;a CORS error almost never means what it says
  • A 404/5xx from upstream has no CORS headers by default, and browsers report that as a CORS failure. Always rule out routing first
  • Double-check the file that's actually deployed matches what you're staring at locally
  • Access-Control-Allow-Origin: * is fine here since this proxy never sends credentials/cookies β€” if you add credentialed requests later, you'll need to echo the real Origin instead of a wildcard
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment