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.
In the Supabase dashboard I created a bucket named images. The first choice was public versus private:
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.
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.
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,
)
}
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:
<project-ref>.supabase.co/storage/v1/object/public/images — without the https:// prefix, which django-storages adds automatically.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.