Django Portfolio Journal

Version 2: Integrating Supabase Storage with Django


The previous article covered the decision to use Supabase for both database and file storage. This article covers the implementation — setting up the bucket, configuring Django, and connecting everything together.

Setting up the storage bucket

In the Supabase dashboard I created a bucket named images. The first choice was public versus private:

  • Private bucket: every file request requires a signed URL with an expiry time. Right for invoices or personal documents.
  • Public bucket: files are served via a permanent CDN URL with no authentication needed.

Portfolio project images are shown to every visitor — private storage would add unnecessary complexity with no benefit. I set the bucket to public.

I also restricted the allowed file types to image/jpeg and image/png with a maximum size of 5 MB. These restrictions match the validate_image_file function already in the codebase, which uses Pillow's img.verify() to check the actual file bytes. The Supabase restrictions are a first-line filter; Pillow is the real security layer because it cannot be fooled by a mismatched MIME type header.

RLS policies

A public bucket still needs Row Level Security policies — without them, nobody can read or write anything. Two policies are required:

CREATE POLICY "Public read images"
ON storage.objects FOR SELECT TO public
USING (bucket_id = 'images');

CREATE POLICY "Authenticated upload images"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'images');

USING controls what rows can be read; WITH CHECK validates rows being written. This is a PostgreSQL RLS distinction.

Database connection: transaction pooler

Supabase offers two connection modes. I chose the Transaction pooler (port 6543) over the direct connection (port 5432). The pooler reuses connections across requests, which is what Supabase recommends for web applications with psycopg2. Direct connections suit long-running processes that need features like PostgreSQL's LISTEN/NOTIFY.

The database is configured via a single DATABASE_URL environment variable using dj-database-url, with SSL enforced in production:

DATABASES = {
    "default": dj_database_url.config(
        default=DATABASE_URL,
        conn_max_age=600,
        ssl_require=True,
    )
}

Configuring django-storages

django-storages was already in my dependencies but boto3 was not — and boto3 is what django-storages uses to speak S3. I added it to pyproject.toml and installed it.

The storage backend switches based on the DEBUG setting: local filesystem in development, Supabase in production.

if DEBUG:
    default_storage_backend = {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
        "OPTIONS": {"location": MEDIA_ROOT},
    }
else:
    default_storage_backend = {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        "OPTIONS": {
            "access_key": config("SUPABASE_S3_ACCESS_KEY_ID"),
            "secret_key": config("SUPABASE_S3_SECRET_ACCESS_KEY"),
            "bucket_name": config("SUPABASE_S3_BUCKET_NAME"),
            "endpoint_url": config("SUPABASE_S3_ENDPOINT_URL"),
            "region_name": config("SUPABASE_S3_REGION_NAME"),
            "addressing_style": "path",
            "querystring_auth": False,
            "signature_version": "s3v4",
            "custom_domain": config("SUPABASE_S3_CUSTOM_DOMAIN"),
        },
    }

A few options need explanation:

  • addressing_style = "path": Supabase does not support AWS's virtual-hosted-style URLs, so boto3 must use path-style instead.
  • querystring_auth = False: disables signed (expiring) URLs. For a public bucket, files are accessible via plain permanent URLs.
  • custom_domain: without this, django-storages generates URLs pointing at the S3 API endpoint, which requires authentication even for public buckets. The value must be <project-ref>.supabase.co/storage/v1/object/public/images — without the https:// prefix, which django-storages adds automatically.

How uploads work

The storage backend is completely transparent to the rest of the code. My management command seeds project images like this:

with open(image_path, "rb") as f:
    project.picture.save(image_path.name, File(f), save=True)

In production, that save() call routes to Supabase via S3Boto3Storage. The database stores only the relative file path. When a template renders {{ project.picture.url }}, the storage backend constructs the full public Supabase URL automatically.

The seed images are committed to git in projects/management/commands/data/images/, so they are available on Render when the seeding command runs on first deploy. The integration itself worked without any code change — only the configuration changed.


© 2026 Dorothea Reher  ·  Impressum  ·  Privacy Policy
Designed by BootstrapMade and modified by Dorothea Reher