All projects
Beta · loading catalogProject 04
ClientMarketplaceAutomotive

RepuestosOriente

Accounts tied to tax IDs, view- and object-level permissions, orders split per seller with frozen prices, and infrastructure that costs $0/month in real production, with the cold start made visible instead of hidden.

Django RESTReactFirebase HostingSupabaseCloudflare R2Render

The infrastructure behind this marketplace costs $0 a month. The price is paid in another currency: the server goes to sleep.

Auto parts with three roles, a checkout that produces one order per seller and a catalog with photos. Four free tiers, a product in real production, and the cold start made visible instead of hidden.

$0
Firebase Hosting
React SPA on a CDN, rewrites to index.html
global CDN
$0
Render
Django + gunicorn, deploys on every push
sleeps without traffic
$0
Supabase
Real Postgres with the pooler included
500 MB
$0
Cloudflare R2
part photos, S3 API, no egress fees
10 GB
$0/mofour services on their free tier
3 rolesbuyer · seller · admin
6 modulesusers · profiles · products · orders · cart · wishlist
01 · How it works

A request travels from the SPA to Postgres through two permission questions

Click the steps or let them advance. Each step lights up the piece it talks about.

React SPA
Firebase Hosting
JWT
who you are
Permissions
view · object
Serializer
one per action
Postgres
Supabase · pooler
Step 1 of 5 · plays on its own
The SPA sends the request with its token
An axios interceptor adds Authorization: Bearer to every call. It is the only place that touches the header: when one screen set it by hand with a different key, it sent Bearer null and failed silently.
Roles and permissions
Buyer
account with RIF, is_seller = false
Browses the catalog without login
Cart, wishlist and questions
One checkout → N orders
Publish products
View other people's orders
Seller
Profile.is_seller = true
Publishes, edits and pauses their own listings
Answers questions on their products
Sees the orders that belong to them
Buy their own product
Touch another seller's products
Admin
User.is_staff = true
Reads every order
Manages sellers and users
Edits any product
Modify someone else's order
Every account registers with company_name and rif: the marketplace is built for businesses buying from each other, and the catalog stays open to the public without login.
Topology · $0/mo
Front
Firebase Hosting
React SPA + Vite, CDN
$0
HTTPS · JSON
API
Render
Django REST · gunicorn · Whitenoise
$0 · sleeps
psycopg2 · sslmode=require
Data
Supabase Postgres
Transaction Pooler :6543 · IPv4
$0 · 500 MB
Cloudflare R2
S3Boto3 · public domain
$0 · 10 GB
02 · An end-to-end example

A repair shop buys from two distributors in a single checkout

A made-up case with small numbers, marked as an example, to see what happens at each stage.

1
Monday 9:10 · example
Taller Los Andes fills the cart
Two sets of brake pads and some spark plugs from Distribuidora Oriente, four oil filters from Repuestos Maturín. They browsed the catalog without login; to buy, they sign in with their account, which has a RIF like any other.
2
POST /orders/from-cart
Everything is validated before anything is created
The product exists, is not paused, has stock, and the buyer is not the seller. If one line fails, the response carries the error per product and no order is created.
3
one transaction
Two orders, one per seller
Lines are grouped by product.seller. Order 1 for Distribuidora Oriente ($36 + $12), order 2 for Repuestos Maturín ($20). Each OrderItem stores the price, original price and discount at that moment. Stock goes down and the cart is emptied.
Cart · 3 lines
Brake pads
Distribuidora Oriente
×2 · $20
Oil filter
Repuestos Maturín
×4 · $5
Spark plugs (set)
Distribuidora Oriente
×1 · $12
group by product.seller
→
Order 1pending
Distribuidora Oriente
2 × brake pads · −10% = $36
1 × spark plugs = $12
total $48
Order 2pending
Repuestos Maturín
4 × oil filter = $20
total $20
4
Tuesday
Distribuidora Oriente raises the price of the brake pads
Monday's order does not change: the snapshot lives in the order, not in the product. Each seller sees only the order that belongs to them; the admin sees them all but does not modify them.
03 · The challenges

Five times the obvious solution was wrong

What anyone would do first, why it does not work, what was done, and the number that proves it. The technical detail stays folded.

01

The free server goes to sleep

The obvious approach
Raise the axios timeout and wait for Render to wake up.
Why it fails
With the instance asleep, the first request gets no response or returns 502. React Query retries once and gives up: the user sees an empty screen or a network error for almost a minute, without knowing that waiting is enough.
GET /products
no response
wait 3 s
overlay: "Connecting…"
retry 1
502 Bad Gateway
wait 3 s
overlay stays
retry 2
200 OK
overlay gone
the app picks up where it was
Counts as a cold start: no response (timeout or network error), 502 or 503. Three retries; if they run out, the overlay offers a retry button.
What was done
The response interceptor tells a cold start apart from other errors: no response, 502 or 503. It retries up to three times with 3 s between attempts and turns on a global state that shows an overlay: "Connecting to the server…". The first good response turns it off. If the retries run out, the overlay explains that the server is in power-saving mode and offers a retry.
3 × 3 sretries and wait between them, with a 30 s timeout per request. The cold start does not go away: it becomes visible.
Technical detail

The state lives outside React, in an object with a Set of listeners the overlay subscribes to. That way the axios interceptor and any hook can turn it on without going through a context.

A 401 follows its own path: it clears the token and redirects to login, unless you are already on an authentication screen. The backend exposes /health/ to check that the instance responded.

Known debt: Render's free tier is the one piece that does not scale. The repository already includes Dockerfile and docker-entrypoint.sh (collectstatic + migrate on boot) to move the API to a VPS without touching the code.

02

One checkout, several sellers

The obvious approach
One order with every line in the cart, like a single-owner store.
Why it fails
Each seller ships their own items and changes the status on their own. With a single order, a seller would see other sellers' lines and a "delivered" would mean nothing. And if the product price changes later, the historical order would change with it.
What was done
A service with one responsibility per class: ProductValidator groups by seller and validates stock, paused state, duplicates and self-purchase; AddressService resolves the address; OrderCreator creates N orders in one atomic transaction. Each line stores the price, original_price and discount at that moment.
1 orderper seller in every checkout. Zero partial orders: if one line fails, none is created.
Technical detail

Validation returns a list of errors per product with a type (own_product, paused_product, insufficient_stock, product_not_found) so the SPA can flag the exact line. The discount is applied as a percentage over original_price × quantity.

03

Permissions DRF does not check when you think it does

The obvious approach
An if user.role == 'seller' in every view.
Why it fails
The role is not on User: it lives in Profile.is_seller, an implicit JOIN in every check that blows up if the profile does not exist. And DRF only calls has_object_permission in retrieve, update and delete: a list that relies on that hook exposes every seller's products.
action
has_permission
has_object_permission
who decides
list
✓ view
— not called
filtered queryset
create
✓ view
— not called
seller or staff
retrieve
✓ view
✓ object
is it theirs?
update
✓ view
✓ object
is it theirs?
destroy
✓ view
✓ object
is it theirs?
What was done
Permission classes with both levels explicit: IsSellerOrAdmin answers at the view level with hasattr as a guard, and at the object level with obj.seller == request.user. Where there is no object, the filter is the queryset: /products/my filters by seller, orders by buyer, and staff reads but does not write.
2 levelsof permission per action, 3 roles resolved at login and also protected per route in the SPA.
Technical detail

The login returns the profile with a computed role (seller or user) and the SPA uses it in RoleProtectedRoute to pick the panel. Route protection is convenience, not security: the real decision is always made by the API.

Public reads without a token: product list and retrieve are AllowAny; everything else requires authentication by default in REST_FRAMEWORK.

04

The photos that worked locally and not in production

The obvious approach
Build each image URL with request.build_absolute_uri(), as with any file under /media/.
Why it fails
With R2 and a public domain, image.url is already absolute: the result was a URL with two schemes and no photo loaded. On the same screen, image upload failed silently: it set the header by hand with the key token while the interceptor stores auth_token, and the API received Bearer null.
build_absolute_uri(url)
https://api.…/https://pub-….r2.dev/media/product_images/a.jpg
Locally the URL was relative and it worked. In prod, R2 already returns it absolute.
if it starts with http(s), pass it through
https://pub-….r2.dev/media/product_images/a.jpg
The same rule works in dev and in prod without checking the environment.
What was done
The serializer returns the URL as is if it starts with http(s) and only builds the absolute one when it is relative. The edit screen stopped touching headers: the interceptor is the only thing that sets them. And after uploading, deleting or reordering photos, the products and seller-products queries are invalidated, because those direct calls did not go through the mutations that did invalidate.
4 bugsclosed on April 15 and 16, 2026: double URL, Bearer null, reorder with an empty list and cache not invalidated.
Technical detail

The fourth was small and noisy: POST /images/order/ was called every time, even with nothing to reorder. Now it is only called when there are existing images.

Storage is S3Boto3Storage with custom_domain pointing to the R2 public domain and location: media; Django's static files are served by Whitenoise with a manifest, no extra bucket.

05

Network is unreachable, with no other clues

The obvious approach
Connect Django to Supabase's direct host, db.….supabase.co, as its dashboard says.
Why it fails
That host resolves to IPv6 and Render's free tier has no IPv6. Django says nothing about it: only Network is unreachable. Neither Render's docs nor Supabase's mention the two together.
What was done
Connect through the Supabase Transaction Pooler, which exposes IPv4 on port 6543, with sslmode=require. And DISABLE_SERVER_SIDE_CURSORS = True, because a pooler in transaction mode keeps no state between queries and Django's server-side cursors assume it does.
:6543pooler port. The sslmode is read from a variable so a Postgres on the same Docker network can use "disable".
Technical detail

Settings are picked by an env variable (local or prod) in the package __init__, the same mechanism the Render deploy used and that the Docker image respects.

"Free is not the same as no cost."
Every free tier charges in something other than money: Render in wake-up latency, Supabase in a stateless pooler, R2 in URLs that look nothing like the local ones. The work was paying those tolls in the code, not on the invoice.
04 · Decisions

One filter: does this add a monthly bill or take time away from the product?

If it added a bill, the equivalent free tier was found. If it took time, whatever comes already solved was chosen.

WHY
Why Django + DRF and not FastAPI?
The model is relational and large: users, profiles, products with variants, orders, cart, wishlist, questions. The ORM, migrations, object-level permissions, django-filter and the admin come solved. FastAPI wins on raw throughput, but here the time of each request is spent in Postgres, not in the framework.
Go deeper

The Django admin is the back office from day one: loading the catalog, verifying sellers, reviewing orders. Writing it by hand would have been half the project.

WHY
Why a custom JWT and not Firebase Auth?
Firebase Auth and Auth0 charge per monthly active user; simplejwt costs the same with 10 accounts as with 10,000. Firebase stays only as hosting for the SPA. The login accepts email or username with a custom backend that returns None instead of raising, so the authentication chain is not cut short.
Go deeper

Tokens are stored in localStorage and a hook refreshes the access token five minutes before it expires, with a lock so two refreshes never run at once.

WHY
Why soft delete with a cascade in Python?
So that deleting a seller does not delete their historical orders. BaseModel sets deleted_at and reimplements Django's cascade by introspecting _meta.related_objects: CASCADE deletes down the chain, SET_NULL nulls the field, ManyToMany is cleared, and restore() undoes the cascade.
Go deeper

A _deletion_context with the keys already visited avoids A→B→A cycles. Two managers per model: objects filters out deleted rows, all_objects does not. It is the most sophisticated code in the project and also the most fragile: any unique constraint can collide with a "deleted" row.

WHY
Why four free tiers and not a $5 VPS?
Because the product was in beta loading the catalog, and $0 with automatic deploys, HTTPS and managed backups is worth more than $5 with all of that still to do. The exit is already prepared: a Docker image with an idempotent entrypoint to move the API to a VPS once the cold start stops being acceptable.
Go deeper

The split: Firebase Hosting serves the SPA from a CDN with a rewrite to index.html; Render builds and deploys the API on every push; Supabase provides Postgres with a pooler; R2 stores the photos with an S3 API and no egress fees; Whitenoise serves Django's static files without one more bucket.

The minimal version of the production data connection, as prod.py reads it:

# Supabase · Transaction Pooler (IPv4)
DB_HOST=aws-…pooler.supabase.com DB_PORT=6543 DB_SSLMODE=require
# Cloudflare R2 · S3 API with public domain
AWS_S3_ENDPOINT_URL=https://<account>.r2.cloudflarestorage.com
R2_PUBLIC_DOMAIN=pub-….r2.dev
05 · What I would do differently

Five decisions I would take the other way today

I did
Soft delete with a manual cascade in Python
Today I would
Hard delete and an audit table only where it matters. The order history is already the audit trail; the cascading restore is a dangerous operation nobody has needed.
I did
Profile.is_seller as the role field
Today I would
Role on Django's User or Group, which exists for this. No implicit JOIN and no hasattr guard in every permission.
I did
price + original_price + discount
Today I would
One final price and a struck-through compare_at_price; the discount is computed. Today nothing prevents price > original_price and the inconsistency is silent.
I did
Service architecture only in Orders
Today I would
Either the pattern across the whole project, or logic in serializers and views across the whole project. Consistency is worth more than isolated sophistication.
I did
No global pagination
Today I would
DEFAULT_PAGINATION_CLASS from day one. Returning the whole catalog is a problem that grows on its own with every product loaded.
06 · If you take three things away

Recap

1
Real production at $0/mo with four free tiers, and every toll of those tiers (cold start, stateless pooler, R2 URLs) solved in the code, not ignored.
2
Three roles with permissions at two levels: the API decides at the view and at the object; the SPA only picks which panel to show.
3
One checkout produces one order per seller, validated in full before anything is created, with the prices frozen in the order.
Links
View demo