Todos los proyectos
Beta · cargando catálogoProyecto 04
ClienteMarketplaceAutomotriz

RepuestosOriente

Cuentas con RIF, permisos a nivel de vista y de objeto, órdenes separadas por vendedor con precios congelados, y una infraestructura que cuesta $0/mes en producción real, con el cold start hecho visible en vez de escondido.

Django RESTReactFirebase HostingSupabaseCloudflare R2Render

La infraestructura de este marketplace cuesta $0 al mes. El precio se paga en otra moneda: el servidor se duerme.

Repuestos automotrices con tres roles, un checkout que produce una orden por vendedor y un catálogo con fotos. Cuatro planes gratuitos, un producto en producción real, y el cold start hecho visible en vez de escondido.

$0
Firebase Hosting
SPA React en CDN, rewrites a index.html
CDN global
$0
Render
Django + gunicorn, deploy con cada push
se duerme sin tráfico
$0
Supabase
Postgres real con pooler incluido
500 MB
$0
Cloudflare R2
fotos de repuestos, API S3, sin egress
10 GB
$0/mescuatro servicios en su plan gratuito
3 rolescomprador · vendedor · admin
6 módulosusers · profiles · products · orders · cart · wishlist
01 · Cómo funciona

Una petición viaja de la SPA a Postgres pasando por dos preguntas de permiso

Pulsa los pasos o deja que avance. Cada paso ilumina la pieza de la que habla.

SPA React
Firebase Hosting
JWT
quién eres
Permisos
vista · objeto
Serializer
uno por acción
Postgres
Supabase · pooler
Paso 1 de 5 · avanza solo
La SPA manda la petición con su token
Un interceptor de axios añade Authorization: Bearer a cada llamada. Es el único sitio que toca el header: cuando una pantalla lo puso a mano con otra clave, mandó Bearer null y falló en silencio.
Modelo de roles
Comprador
cuenta con RIF, is_seller = false
Busca el catálogo sin login
Carrito, wishlist y preguntas
Un checkout → N órdenes
Publicar productos
Ver órdenes ajenas
Vendedor
Profile.is_seller = true
Publica, edita y pausa lo suyo
Responde preguntas de sus productos
Ve las órdenes que le tocan
Comprar su propio producto
Tocar productos de otro vendedor
Admin
User.is_staff = true
Lee todas las órdenes
Gestiona vendedores y usuarios
Edita cualquier producto
Modificar una orden ajena
Toda cuenta se registra con company_name y rif: el marketplace nace pensado para negocios que se compran entre sí, y el catálogo queda abierto al público sin login.
Topología · $0/mes
Front
Firebase Hosting
SPA React + Vite, CDN
$0
HTTPS · JSON
API
Render
Django REST · gunicorn · Whitenoise
$0 · se duerme
psycopg2 · sslmode=require
Datos
Supabase Postgres
Transaction Pooler :6543 · IPv4
$0 · 500 MB
Cloudflare R2
S3Boto3 · dominio público
$0 · 10 GB
02 · Un ejemplo de punta a punta

Un taller compra a dos distribuidoras en un solo checkout

Un caso inventado con números pequeños, marcado como ejemplo, para ver qué pasa en cada tramo.

1
lunes 9:10 · ejemplo
Taller Los Andes llena el carrito
Dos juegos de pastillas de freno y unas bujías de Distribuidora Oriente, cuatro filtros de aceite de Repuestos Maturín. El catálogo lo vio sin login; para comprar entra con su cuenta, que tiene RIF como cualquier otra.
2
POST /orders/from-cart
Se valida todo antes de crear nada
Producto existe, no está pausado, hay stock y el comprador no es el vendedor. Si una línea falla, la respuesta trae el error por producto y no se crea ninguna orden.
3
una transacción
Dos órdenes, una por vendedor
Las líneas se agrupan por product.seller. Orden 1 para Distribuidora Oriente ($36 + $12), orden 2 para Repuestos Maturín ($20). Cada OrderItem guarda precio, precio original y descuento del momento. El stock baja y el carrito se vacía.
Carrito · 3 líneas
Pastillas de freno
Distribuidora Oriente
×2 · $20
Filtro de aceite
Repuestos Maturín
×4 · $5
Bujías (juego)
Distribuidora Oriente
×1 · $12
agrupar por product.seller
→
Orden 1pending
Distribuidora Oriente
2 × pastillas · −10 % = $36
1 × bujías = $12
total $48
Orden 2pending
Repuestos Maturín
4 × filtro = $20
total $20
4
martes
Distribuidora Oriente sube el precio de las pastillas
La orden del lunes no cambia: el snapshot vive en la orden, no en el producto. Cada vendedor ve solo la orden que le toca; el admin las ve todas, pero no las modifica.
03 · Los retos

Cinco veces en que la solución obvia estaba mal

Lo que cualquiera haría primero, por qué no sirve, lo que se hizo y el número que lo demuestra. El detalle técnico queda plegado.

01

El servidor gratuito se duerme

Lo obvio
Subir el timeout de axios y esperar a que Render despierte.
Por qué falla
Con la instancia dormida, la primera petición no responde o devuelve 502. React Query reintenta una vez y se rinde: el usuario ve una pantalla vacía o un error de red durante casi un minuto, sin saber que basta con esperar.
GET /products
sin respuesta
espera 3 s
overlay: "Conectando…"
reintento 1
502 Bad Gateway
espera 3 s
overlay sigue
reintento 2
200 OK
overlay fuera
la app sigue donde iba
Cuenta como cold start: sin respuesta (timeout o error de red), 502 o 503. Tres reintentos; si se agotan, el overlay ofrece un botón de reintentar.
Lo que se hizo
El interceptor de respuesta distingue el cold start del resto de errores: sin respuesta, 502 o 503. Reintenta hasta tres veces con 3 s entre intentos y enciende un estado global que muestra un overlay: "Conectando con el servidor…". La primera respuesta buena lo apaga. Si se agotan los reintentos, el overlay explica que el servidor está en ahorro de energía y ofrece reintentar.
3 × 3 sreintentos y espera entre ellos, con timeout de 30 s por petición. El cold start no desaparece: se hace visible.
Ver detalle técnico

El estado vive fuera de React, en un objeto con un Set de listeners que el overlay suscribe. Así lo pueden encender el interceptor de axios y cualquier hook sin pasar por un contexto.

Un 401 sigue su propio camino: limpia el token y redirige a login, salvo que ya estés en una pantalla de autenticación. El backend expone /health/ para comprobar que la instancia respondió.

Deuda consciente: el plan gratuito de Render es el único punto que no escala. El repositorio ya incluye Dockerfile y docker-entrypoint.sh (collectstatic + migrate al arrancar) para mover la API a un VPS sin tocar el código.

02

Un checkout, varios vendedores

Lo obvio
Una orden con todas las líneas del carrito, como en una tienda de un solo dueño.
Por qué falla
Cada vendedor despacha lo suyo y cambia el estado por su cuenta. Con una sola orden, un vendedor vería líneas ajenas y un "entregado" no significaría nada. Y si el precio del producto cambia después, la orden histórica cambiaría con él.
Lo que se hizo
Un servicio con una responsabilidad por clase: ProductValidator agrupa por vendedor y valida stock, pausa, duplicados y compra propia; AddressService resuelve la dirección; OrderCreator crea N órdenes en una transacción atómica. Cada línea guarda price, original_price y discount del momento.
1 ordenpor vendedor en cada checkout. Cero órdenes parciales: si una línea falla, no se crea ninguna.
Ver detalle técnico

La validación devuelve una lista de errores por producto con tipo (own_product, paused_product, insufficient_stock, product_not_found) para que la SPA señale la línea exacta. El descuento se aplica como porcentaje sobre original_price × cantidad.

03

Permisos que DRF no pregunta cuando crees

Lo obvio
Un if user.role == 'seller' en cada vista.
Por qué falla
El rol no está en User: está en Profile.is_seller, un JOIN implícito en cada comprobación que explota si el perfil no existe. Y DRF solo llama a has_object_permission en retrieve, update y delete: un list que confíe en ese hook expone los productos de todos los vendedores.
acción
has_permission
has_object_permission
quién decide
list
✓ vista
— no se llama
queryset filtrado
create
✓ vista
— no se llama
seller o staff
retrieve
✓ vista
✓ objeto
¿es suyo?
update
✓ vista
✓ objeto
¿es suyo?
destroy
✓ vista
✓ objeto
¿es suyo?
Lo que se hizo
Clases de permiso con los dos niveles explícitos: IsSellerOrAdmin responde a nivel de vista con hasattr como guardia, y a nivel de objeto con obj.seller == request.user. Donde no hay objeto, el filtro es el queryset: /products/my filtra por vendedor, las órdenes por comprador, y el staff lee pero no escribe.
2 nivelesde permiso por acción, 3 roles resueltos en el login y protegidos también por ruta en la SPA.
Ver detalle técnico

El login devuelve el perfil con role calculado (seller o user) y la SPA lo usa en RoleProtectedRoute para elegir panel. La protección de ruta es comodidad, no seguridad: la decisión real siempre la toma la API.

Lectura pública sin token: list y retrieve de productos son AllowAny; el resto exige autenticación por defecto en REST_FRAMEWORK.

04

Las fotos que funcionaban en local y no en producción

Lo obvio
Construir la URL de cada imagen con request.build_absolute_uri(), como con cualquier fichero en /media/.
Por qué falla
Con R2 y dominio público, image.url ya es absoluta: el resultado era una URL con dos esquemas y ninguna foto cargaba. En la misma pantalla, la subida de imágenes fallaba en silencio: ponía el header a mano con la clave token cuando el interceptor guarda auth_token, y la API recibía Bearer null.
build_absolute_uri(url)
https://api.…/https://pub-….r2.dev/media/product_images/a.jpg
En local la URL era relativa y funcionaba. En prod, R2 ya la devuelve absoluta.
si empieza por http(s), tal cual
https://pub-….r2.dev/media/product_images/a.jpg
La misma regla sirve en dev y en prod sin mirar el entorno.
Lo que se hizo
El serializer devuelve la URL tal cual si empieza por http(s) y solo construye la absoluta cuando es relativa. La pantalla de edición dejó de tocar headers: el interceptor es el único que los pone. Y tras subir, borrar o reordenar fotos se invalidan las queries products y seller-products, porque esas llamadas directas no pasaban por las mutaciones que sí invalidaban.
4 bugscerrados el 15 y 16 de abril de 2026: URL doble, Bearer null, reorden con lista vacía y caché sin invalidar.
Ver detalle técnico

El cuarto era pequeño y ruidoso: POST /images/order/ se llamaba siempre, aunque no hubiera nada que reordenar. Ahora solo se llama cuando hay imágenes existentes.

El almacenamiento es S3Boto3Storage con custom_domain apuntando al dominio público de R2 y location: media; los estáticos de Django los sirve Whitenoise con manifest, sin bucket extra.

05

Network is unreachable, sin más pistas

Lo obvio
Conectar Django al host directo de Supabase, db.….supabase.co, como dice su panel.
Por qué falla
Ese host resuelve a IPv6 y el plan gratuito de Render no tiene IPv6. Django no dice nada de eso: solo Network is unreachable. Ni la documentación de Render ni la de Supabase lo mencionan juntas.
Lo que se hizo
Conectar por el Transaction Pooler de Supabase, que expone IPv4 en el puerto 6543, con sslmode=require. Y DISABLE_SERVER_SIDE_CURSORS = True, porque un pooler en modo transacción no conserva estado entre consultas y los cursores de servidor de Django lo asumen.
:6543puerto del pooler. El sslmode se lee de una variable para que un Postgres en la misma red Docker pueda usar "disable".
Ver detalle técnico

Las settings se eligen por una variable env (local o prod) en el __init__ del paquete, el mismo mecanismo que usó el deploy en Render y que respeta la imagen Docker.

"Gratis no es lo mismo que sin coste."
Cada plan gratuito cobra en algo que no es dinero: Render en latencia al despertar, Supabase en un pooler sin estado, R2 en URLs que no se parecen a las de local. El trabajo fue pagar esos peajes en el código, no en la factura.
04 · Decisiones

Un filtro: ¿esto añade una factura mensual o quita tiempo de producto?

Si añadía factura, se buscó el plan gratuito equivalente. Si quitaba tiempo, se eligió lo que ya viene resuelto.

WHY
¿Por qué Django + DRF y no FastAPI?
El modelo es relacional y grande: usuarios, perfiles, productos con variantes, órdenes, carrito, wishlist, preguntas. El ORM, las migraciones, los permisos por objeto, django-filter y el admin vienen resueltos. FastAPI gana en throughput puro, pero aquí el tiempo de cada petición está en Postgres, no en el framework.
Profundizar

El admin de Django es el back-office desde el primer día: cargar catálogo, verificar vendedores, revisar órdenes. Escribirlo a mano habría sido la mitad del proyecto.

WHY
¿Por qué JWT propio y no Firebase Auth?
Firebase Auth y Auth0 cobran por usuarios activos al mes; simplejwt cuesta lo mismo con 10 cuentas que con 10 000. Firebase se queda solo como hosting de la SPA. El login acepta email o usuario con un backend propio que devuelve None en vez de lanzar, para no cortar la cadena de autenticación.
Profundizar

Los tokens se guardan en localStorage y un hook renueva el access token cinco minutos antes de que expire, con un candado para no lanzar dos refresh a la vez.

WHY
¿Por qué soft delete con cascada en Python?
Para que borrar un vendedor no borre sus órdenes históricas. BaseModel marca deleted_at y reimplementa la cascada de Django introspeccionando _meta.related_objects: CASCADE borra en cadena, SET_NULL anula el campo, ManyToMany se limpia, y restore() deshace la cascada.
Profundizar

Un _deletion_context con las claves ya visitadas evita los ciclos A→B→A. Dos managers por modelo: objects filtra los borrados, all_objects no. Es el código más sofisticado del proyecto y también el más frágil: cualquier constraint único puede chocar con una fila "borrada".

WHY
¿Por qué cuatro planes gratuitos y no un VPS de $5?
Porque el producto estaba en beta cargando catálogo, y $0 con deploy automático, HTTPS y backups gestionados vale más que $5 con todo eso por hacer. La salida ya está preparada: imagen Docker con entrypoint idempotente para mover la API a un VPS cuando el cold start deje de ser aceptable.
Profundizar

Reparto: Firebase Hosting sirve la SPA desde CDN con rewrite a index.html; Render construye y despliega la API con cada push; Supabase da Postgres con pooler; R2 guarda las fotos con API S3 y sin cobrar egress; Whitenoise sirve los estáticos de Django sin un bucket más.

La versión mínima de la conexión a datos en producción, tal como la lee prod.py:

# Supabase · Transaction Pooler (IPv4)
DB_HOST=aws-…pooler.supabase.com DB_PORT=6543 DB_SSLMODE=require
# Cloudflare R2 · API S3 con dominio público
AWS_S3_ENDPOINT_URL=https://<account>.r2.cloudflarestorage.com
R2_PUBLIC_DOMAIN=pub-….r2.dev
05 · Qué haría distinto

Cinco decisiones que hoy tomaría al revés

Hice
Soft delete con cascada manual en Python
Hoy haría
Hard delete y tabla de auditoría solo donde importa. El historial de órdenes ya es el audit trail; el restore en cascada es una operación peligrosa que nadie ha necesitado.
Hice
Profile.is_seller como campo de rol
Hoy haría
Rol en User o Group de Django, que existe para esto. Sin JOIN implícito ni hasattr de guardia en cada permiso.
Hice
price + original_price + discount
Hoy haría
Un price final y un compare_at_price tachado; el descuento se calcula. Hoy nada impide price > original_price y la inconsistencia es silenciosa.
Hice
Arquitectura de servicios solo en Orders
Hoy haría
O el patrón en todo el proyecto, o lógica en serializers y vistas en todo el proyecto. La consistencia vale más que la sofisticación puntual.
Hice
Sin paginación global
Hoy haría
DEFAULT_PAGINATION_CLASS desde el primer día. Devolver el catálogo entero es un problema que crece solo con cada producto cargado.
06 · Si te llevas tres cosas

Recap

1
Producción real a $0/mes con cuatro planes gratuitos, y cada peaje de esos planes (cold start, pooler sin estado, URLs de R2) resuelto en el código, no ignorado.
2
Tres roles con permisos en dos niveles: la API decide en la vista y en el objeto; la SPA solo elige qué panel mostrar.
3
Un checkout produce una orden por vendedor, validada entera antes de crear nada, con los precios congelados en la orden.
Enlaces
Ver demo