Tema
PostgreSQL en la nube
Administrar tu propio servidor de base de datos (actualizaciones, respaldos, réplicas, seguridad) es trabajo de tiempo completo. Para la mayoría de proyectos es más inteligente usar un PostgreSQL administrado: tú creas tablas y consultas; el proveedor se encarga del resto. En esta lección conocerás Supabase y Neon, los servicios más populares entre desarrolladores (ambos con plan gratuito), otras alternativas, el tema clave del connection pooling, y harás un proyecto práctico: desplegar la base de la tienda y conectarle una API en Node.
Concepto
| Opción | Tú administras | Ideal para |
|---|---|---|
| Servidor propio (VPS) | Todo: SO, PostgreSQL, respaldos, seguridad | Control total, costos fijos, equipos con experiencia |
| PostgreSQL administrado (RDS, Cloud SQL, DigitalOcean) | Solo la base de datos | Empresas, cargas constantes |
| Plataformas para desarrolladores (Supabase, Neon) | Solo tus datos; extras incluidos | Startups, freelancers, MVPs, proyectos de clientes |
Es como elegir entre comprar una casa (VPS), rentar un departamento con mantenimiento (administrado) o un departamento amueblado con servicios incluidos (Supabase).
Supabase
Supabase es una plataforma "backend como servicio" construida sobre PostgreSQL real. Además de la base de datos te da:
- API REST y GraphQL automáticas a partir de tus tablas.
- Auth: registro e inicio de sesión (email, Google, GitHub...), con usuarios en
auth.users. - Storage: almacenamiento de archivos con permisos.
- Realtime: escuchar cambios de tablas en vivo.
- Edge Functions: funciones serverless.
- Panel con editor SQL, editor de tablas y Row Level Security integrado.
Crear el proyecto
- Entra a
supabase.com, crea una cuenta y un New project. - Elige nombre, contraseña de la base de datos (guárdala) y la región más cercana a tus usuarios o a tu servidor (por ejemplo,
us-east-1o la región de México/Sudamérica si está disponible). - Espera un par de minutos a que se aprovisione.
El editor SQL
En SQL Editor puedes pegar directamente tus scripts. Todo lo que aprendiste en este curso funciona igual: es PostgreSQL. Las tablas van en el esquema public por defecto.
sql
CREATE TABLE public.productos (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nombre TEXT NOT NULL,
precio NUMERIC(10,2) NOT NULL CHECK (precio >= 0),
stock INT NOT NULL DEFAULT 0,
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);Cadenas de conexión
En Project Settings → Database (o el botón Connect) encontrarás tres formas de conectarte:
| Modo | Puerto | Cuándo usarlo |
|---|---|---|
| Direct connection | 5432 | Servidores de larga duración (VPS, contenedores) con IPv6; migraciones |
| Session pooler (Supavisor) | 5432 | Como la directa pero vía IPv4 |
| Transaction pooler (Supavisor) | 6543 | Serverless (Vercel, Netlify, Lambda): muchas conexiones cortas |
env
# Formato típico (los valores exactos los copias del panel)
DATABASE_URL="postgresql://postgres.abcdefghijk:TU_CLAVE@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
DIRECT_URL="postgresql://postgres.abcdefghijk:TU_CLAVE@aws-0-us-east-1.pooler.supabase.com:5432/postgres"Con Prisma se usan ambas: url para la app (pooler) y directUrl para migraciones:
txt
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}Row Level Security en Supabase
La API de Supabase expone tus tablas al navegador usando una clave pública (anon key). Por eso RLS es obligatorio: sin políticas, cualquiera con la clave podría leer o modificar todo.
sql
-- Pedidos ligados al usuario autenticado de Supabase Auth
CREATE TABLE public.pedidos (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
usuario_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE DEFAULT auth.uid(),
total NUMERIC(10,2) NOT NULL,
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.pedidos ENABLE ROW LEVEL SECURITY;
-- Cada usuario ve solo sus pedidos
CREATE POLICY "ver mis pedidos" ON public.pedidos
FOR SELECT TO authenticated
USING (usuario_id = (SELECT auth.uid()));
-- Cada usuario solo crea pedidos a su nombre
CREATE POLICY "crear mis pedidos" ON public.pedidos
FOR INSERT TO authenticated
WITH CHECK (usuario_id = (SELECT auth.uid()));
-- Catálogo público de solo lectura
ALTER TABLE public.productos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "catalogo publico" ON public.productos
FOR SELECT TO anon, authenticated
USING (true);auth.uid() devuelve el UUID del usuario según su token JWT. Envolverlo en (SELECT auth.uid()) permite que PostgreSQL lo evalúe una sola vez por consulta (mejor rendimiento).
Las dos claves de Supabase
- anon / publishable key: pública, va en el frontend. Segura solo si tienes RLS en todas las tablas expuestas.
- service_role / secret key: ignora RLS por completo. Solo en tu servidor, nunca en el navegador ni en un repositorio público.
El panel marca en rojo las tablas sin RLS. Tómalo en serio.
Usarlo desde JavaScript
bash
npm install @supabase/supabase-jsjs
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://abcdefghijk.supabase.co',
'TU_ANON_KEY'
);
// SELECT con filtros (RLS decide qué filas se devuelven)
const { data: productos, error } = await supabase
.from('productos')
.select('id, nombre, precio')
.lt('precio', 500)
.order('precio');
// Llamar a una función de PostgreSQL (RPC), por ejemplo el buscador de la lección 8
const { data: resultados } = await supabase.rpc('buscar_productos', { p_texto: 'audifonos', p_limite: 10 });También puedes ignorar el SDK y conectarte con pg, Prisma o PDO usando la cadena de conexión, como cualquier PostgreSQL.
Neon
Neon es PostgreSQL serverless: separa el almacenamiento del cómputo, lo que le permite cosas únicas:
| Característica | Qué significa |
|---|---|
| Escala a cero | Si nadie usa la base, el cómputo se suspende (no pagas) y despierta en ~0.5 s con la primera consulta |
| Branching | Crear una rama de tu base (con todos sus datos) en segundos, como una rama de Git |
| Restauración por tiempo | Crear una rama "como estaba la base hace 3 horas" |
| Autoscaling | Más CPU/RAM en picos de tráfico automáticamente |
| Driver serverless | Consultas por HTTP/WebSocket desde entornos edge |
Crear el proyecto
- Entra a
neon.tech→ New Project → nombre, versión de PostgreSQL y región. - Neon crea la base
neondb, el rol dueño y la ramamain. - En Connection Details copia la cadena. Marca Pooled connection para apps serverless (el host incluye
-pooler).
env
DATABASE_URL="postgresql://tienda_owner:CLAVE@ep-ejemplo-123456-pooler.us-east-2.aws.neon.tech/tienda?sslmode=require"
DIRECT_URL="postgresql://tienda_owner:CLAVE@ep-ejemplo-123456.us-east-2.aws.neon.tech/tienda?sslmode=require"Branching: el superpoder
txt
main (producción) ──●──────●──────●──────────────►
\
●── rama "feature-cupones" (copia instantánea con datos reales)
├─ pruebas la migración aquí
└─ si sale bien, la aplicas en main; si no, borras la ramabash
npm install -g neonctl
neonctl auth
neonctl branches create --name feature-cupones
neonctl connection-string feature-cupones
neonctl branches delete feature-cuponesCasos de uso: probar migraciones con datos reales, una base por cada preview deployment de Vercel, entornos de prueba desechables, recuperar datos borrados por error.
Driver serverless
En funciones edge (Vercel Edge, Cloudflare Workers) no siempre hay sockets TCP. Neon ofrece un driver por HTTP:
bash
npm install @neondatabase/serverlessjs
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL);
// Tagged template: los valores se envían como parámetros (seguro)
const precioMax = 500;
const productos = await sql`SELECT id, nombre, precio FROM productos WHERE precio < ${precioMax}`;Otras opciones
| Servicio | Destaca por | Plan gratuito |
|---|---|---|
| Railway | Despliegue de app + base en un mismo lugar, muy simple | Créditos de prueba |
| Render | PostgreSQL administrado junto a tus servicios web | Limitado / temporal |
| DigitalOcean Managed Databases | Precio predecible, buena relación costo/beneficio | No |
| AWS RDS / Aurora | Estándar empresarial, máxima configurabilidad | Capa gratuita 12 meses |
| Google Cloud SQL / AlloyDB | Integración con GCP | Créditos |
| Azure Database for PostgreSQL | Integración con Microsoft | Créditos |
| Aiven, Crunchy Bridge, Xata | PostgreSQL administrado especializado | Varía |
(Los planes y precios cambian con frecuencia: revisa siempre la página oficial de cada proveedor antes de recomendarlo a un cliente.)
Connection pooling: el tema que más problemas causa
Cada conexión de PostgreSQL es un proceso en el servidor que consume memoria (varios MB). Un plan pequeño admite quizá 60–100 conexiones.
En plataformas serverless (Vercel, Netlify, AWS Lambda), cada invocación puede abrir su propia conexión. Con tráfico, en segundos tienes cientos:
txt
Sin pooler Con pooler (PgBouncer / Supavisor / Neon)
────────── ───────────────────────────────────────
Función 1 ──┐ Función 1 ──┐
Función 2 ──┤ Función 2 ──┤ ┌────────┐
Función 3 ──┼──► PostgreSQL Función 3 ──┼────► │ pooler │ ──► PostgreSQL
... │ "FATAL: too many ... │ └────────┘ (20 conexiones
Función 300─┘ connections" Función 300─┘ reales)Reglas prácticas:
| Entorno | Qué usar |
|---|---|
| Serverless / edge | La URL pooled (modo transacción) del proveedor; connection_limit bajo en Prisma (?connection_limit=1) |
| Servidor tradicional (VPS, contenedor) | Conexión directa + un pool en tu app (max: 10 en pg) |
Migraciones (prisma migrate, DDL) | Conexión directa (los poolers en modo transacción no soportan todo) |
Limitaciones del modo transacción
En modo transaction pooling, la conexión real se presta solo durante cada transacción. Por eso no funcionan bien: SET de sesión, LISTEN/NOTIFY, prepared statements con nombre (en algunos poolers) ni advisory locks de sesión. Con Prisma agrega ?pgbouncer=true a la URL pooled de Supabase para que desactive las preparadas con nombre.
Proyecto práctico: desplegar la tienda
Vas a llevar la base de la tienda a la nube (Neon o Supabase) y conectarle una API Express desplegable.
1. Crear la base y el esquema
Crea el proyecto en Neon o Supabase y ejecuta en su editor SQL (o con psql "$DIRECT_URL" -f esquema.sql):
sql
-- esquema.sql — tienda en PostgreSQL
CREATE TABLE categorias (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nombre TEXT NOT NULL UNIQUE
);
CREATE TABLE productos (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nombre TEXT NOT NULL,
categoria_id INT REFERENCES categorias(id) ON DELETE SET NULL,
precio NUMERIC(10,2) NOT NULL CHECK (precio >= 0),
stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
atributos JSONB NOT NULL DEFAULT '{}',
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_productos_categoria ON productos (categoria_id);
CREATE TABLE clientes (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
ciudad TEXT,
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE pedidos (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
cliente_id INT NOT NULL REFERENCES clientes(id) ON DELETE RESTRICT,
estado TEXT NOT NULL DEFAULT 'pendiente'
CHECK (estado IN ('pendiente','pagado','enviado','entregado','cancelado')),
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_pedidos_cliente ON pedidos (cliente_id, creado_en DESC);
CREATE TABLE detalle_pedido (
pedido_id INT REFERENCES pedidos(id) ON DELETE CASCADE,
producto_id INT REFERENCES productos(id) ON DELETE RESTRICT,
cantidad INT NOT NULL CHECK (cantidad > 0),
precio_unitario NUMERIC(10,2) NOT NULL,
PRIMARY KEY (pedido_id, producto_id)
);
CREATE INDEX idx_detalle_producto ON detalle_pedido (producto_id);
-- Datos iniciales
INSERT INTO categorias (nombre) VALUES ('Electrónica'), ('Libros'), ('Hogar'), ('Deportes');
INSERT INTO productos (nombre, categoria_id, precio, stock) VALUES
('Audífonos Bluetooth', 1, 899.00, 25), ('Teclado mecánico', 1, 1250.00, 10),
('Monitor 24 pulgadas', 1, 3499.00, 5), ('Libro Aprende SQL', 2, 349.00, 40),
('Cien años de soledad', 2, 289.00, 30), ('Lámpara de escritorio', 3, 459.00, 15),
('Taza térmica', 3, 199.00, 50), ('Cable USB-C', NULL, 99.00, 100);
INSERT INTO clientes (nombre, email, ciudad) VALUES
('Ana López', 'ana@correo.com', 'Ciudad de México'),
('Carlos Ruiz', 'carlos@correo.com', 'Guadalajara');Si ya tienes la base en tu computadora, migra con pg_dump (ver Respaldos):
bash
pg_dump -d tienda -Fc --no-owner --no-privileges -f tienda.dump
pg_restore -d "$DIRECT_URL" --no-owner --no-privileges tienda.dump2. Crear un rol para la app (no uses el dueño)
sql
CREATE ROLE tienda_api WITH LOGIN PASSWORD 'GENERA_UNA_CLAVE_LARGA';
GRANT USAGE ON SCHEMA public TO tienda_api;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tienda_api;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tienda_api;(En Neon también puedes crear roles desde el panel; en Supabase, para APIs propias suele usarse el rol postgres con la conexión del pooler, pero un rol dedicado es más seguro.)
3. La API
bash
mkdir tienda-api && cd tienda-api
npm init -y
npm pkg set type=module
npm install express pg dotenvjs
// db.js
import 'dotenv/config';
import pg from 'pg';
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL, // URL pooled con ?sslmode=require
max: 5,
ssl: { rejectUnauthorized: true }
});js
// server.js
import express from 'express';
import { pool } from './db.js';
const app = express();
app.use(express.json());
app.get('/salud', async (req, res) => {
const { rows } = await pool.query('SELECT now() AS hora, version() AS version');
res.json(rows[0]);
});
app.get('/productos', async (req, res, next) => {
try {
const categoria = req.query.categoria ?? null;
const { rows } = await pool.query(
`SELECT p.id, p.nombre, p.precio::float AS precio, p.stock, c.nombre AS categoria
FROM productos p
LEFT JOIN categorias c ON c.id = p.categoria_id
WHERE ($1::text IS NULL OR c.nombre = $1)
ORDER BY p.nombre`,
[categoria]
);
res.json(rows);
} catch (err) { next(err); }
});
app.post('/pedidos', async (req, res, next) => {
const { clienteId, items } = req.body; // items: [{ productoId, cantidad }]
if (!Number.isInteger(clienteId) || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'Datos inválidos' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: [pedido] } = await client.query(
'INSERT INTO pedidos (cliente_id) VALUES ($1) RETURNING id, uuid',
[clienteId]
);
// Ordenar por id evita deadlocks entre pedidos simultáneos
for (const { productoId, cantidad } of [...items].sort((a, b) => a.productoId - b.productoId)) {
const { rows } = await client.query(
`UPDATE productos SET stock = stock - $1
WHERE id = $2 AND stock >= $1
RETURNING precio`,
[cantidad, productoId]
);
if (rows.length === 0) throw Object.assign(new Error('Sin stock'), { status: 409, productoId });
await client.query(
'INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad, precio_unitario) VALUES ($1, $2, $3, $4)',
[pedido.id, productoId, cantidad, rows[0].precio]
);
}
await client.query('COMMIT');
res.status(201).json({ pedido: pedido.uuid });
} catch (err) {
await client.query('ROLLBACK');
next(err);
} finally {
client.release();
}
});
app.use((err, req, res, next) => {
console.error(err);
if (err.status === 409) return res.status(409).json({ error: err.message, productoId: err.productoId });
if (err.code === '23503') return res.status(400).json({ error: 'Cliente o producto inexistente' });
res.status(500).json({ error: 'Error interno' });
});
const puerto = process.env.PORT ?? 3000;
app.listen(puerto, () => console.log(`API en http://localhost:${puerto}`));env
# .env
DATABASE_URL="postgresql://tienda_api:CLAVE@HOST-POOLER/tienda?sslmode=require"bash
node server.js
curl http://localhost:3000/salud
curl "http://localhost:3000/productos?categoria=Libros"
curl -X POST http://localhost:3000/pedidos -H "Content-Type: application/json" \
-d '{"clienteId":1,"items":[{"productoId":2,"cantidad":1},{"productoId":7,"cantidad":2}]}'4. Desplegar
Sube el proyecto a GitHub (sin el .env) y despliégalo en Railway, Render, Fly.io o un VPS. En el panel del servicio define la variable de entorno DATABASE_URL. Elige la misma región para la app y la base: cada consulta entre continentes añade 100–200 ms.
Checklist de producción
- [ ] Región de la base = región de la app.
- [ ] Rol de aplicación con privilegios mínimos; la contraseña del dueño solo para migraciones.
- [ ]
sslmode=requirey verificación de certificado. - [ ] URL pooled para serverless; directa para migraciones.
- [ ] RLS activado en toda tabla expuesta por APIs públicas (Supabase).
- [ ] La clave
service_role/ secretos solo en el servidor. - [ ] Índices en FK y consultas frecuentes;
pg_stat_statementsrevisado periódicamente. - [ ] Respaldos: conoce la retención y el PITR de tu plan, y haz además un
pg_dumppropio periódico fuera del proveedor. - [ ] Alertas de uso (almacenamiento, conexiones, cómputo) para no llevarte sorpresas en la factura.
- [ ] Migraciones versionadas (Prisma, node-pg-migrate, Supabase CLI) y probadas en una rama o entorno de staging.
Ejercicios
- Crea un proyecto gratuito en Neon o Supabase y ejecuta el
esquema.sqldel proyecto. - Conéctate con
psqlusando la cadena de conexión directa y ejecuta\dt. - (Supabase) Activa RLS en
productosy crea una política de solo lectura pública. Prueba con el SDK y laanon keyque puedes leer pero no insertar. - (Neon) Crea una rama, agrega en ella una columna
descuentoaproductosy comprueba quemainno cambió. - ¿Qué URL de conexión usarías para una app Next.js desplegada en Vercel y por qué?
Ver solución
bash
# 2
psql "postgresql://usuario:clave@host:5432/db?sslmode=require" -c "\dt"sql
-- 3
ALTER TABLE public.productos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "lectura publica" ON public.productos
FOR SELECT TO anon, authenticated USING (true);
-- Sin política de INSERT, un insert con la anon key falla:
-- "new row violates row-level security policy for table productos"bash
# 4
neonctl branches create --name prueba-descuento
psql "$(neonctl connection-string prueba-descuento)" -c "ALTER TABLE productos ADD COLUMN descuento NUMERIC(5,2) DEFAULT 0;"
psql "$(neonctl connection-string main)" -c "\d productos" # sin la columna descuento
neonctl branches delete prueba-descuento- La URL pooled (Supavisor en modo transacción, o el host
-poolerde Neon), porque cada invocación serverless abre conexiones nuevas y sin pooler se agotaría el límite de conexiones de PostgreSQL. Paraprisma migrate deployse usa la URL directa (directUrl).
Resumen
- Un PostgreSQL administrado te libera de servidores, respaldos y actualizaciones.
- Supabase: PostgreSQL + API automática, Auth, Storage y Realtime; RLS es obligatorio y la
service_rolenunca va al frontend. - Neon: PostgreSQL serverless con escala a cero, branching y restauración por tiempo.
- El connection pooling es clave en serverless: URL pooled para la app, directa para migraciones.
- Despliega app y base en la misma región, con SSL, roles mínimos y respaldos propios.