En mi experiencia, elegir dónde guardar los datos es la decisión que más condiciona un proyecto: cambiar de base de datos a mitad de camino es de lo más caro que hay. Si tantas opciones te abruman, es normal; a mí me pasó. En este capítulo te cuento cuándo uso cada una de las de Cloudflare, cuándo me voy fuera (Supabase, Neon, PlanetScale, Turso o Aurora PostgreSQL) y cómo se conecta cada una desde un Worker, con código.
Para quién es y qué necesitas
Para quien tiene el Worker del capítulo anterior y va a guardar datos por primera vez, o duda entre D1 y una base de datos externa.
Antes de empezar:
- El proyecto de Workers desde cero funcionando en local.
- SQL básico (
SELECT,INSERT,WHERE). - Si vas a usar una base de datos externa: una cuenta en ese proveedor y su cadena de conexión a mano. Te digo cuál copiar en cada caso, porque no vale cualquiera.
Al terminar tendrás:
- Una tabla de notas en D1 con sus migraciones, también en las pruebas.
- Un criterio para saber cuándo quedarte en Cloudflare y cuándo irte fuera.
- El código para conectar con Postgres, MySQL o Turso desde un Worker.
Qué opción elegir
| Si necesitas… | Elige |
|---|---|
| Datos relacionales con consultas, hasta 10 GB por base de datos | D1 |
| Configuración, sesiones o caché que se leen mucho y cambian pocoConsistencia eventual: un cambio tarda hasta ~60 s en verse en todo el mundo | KV |
| Estado por usuario, sala o documento, con coordinación o tiempo real | Durable Objects |
| Base de datos grande, ya existente o con extensiones de Postgres | Hyperdrive + Postgres |
| Un servicio con API HTTP (Upstash Redis, MongoDB Atlas Data API, Turso) | API HTTP externa |
flowchart TD
A[¿Qué datos?] --> B{¿Relacionales?}
B -->|Sí| C{¿Más de 10 GB<br/>o ya tienes Postgres?}
C -->|No| D1[D1]
C -->|Sí| HD[Hyperdrive + Postgres]
B -->|No| E{¿Por entidad y<br/>con coordinación?}
E -->|Sí| DO[Durable Objects]
E -->|No| F{¿Muchas lecturas,<br/>pocas escrituras?}
F -->|Sí| KV[KV]
F -->|No| D1
D1: SQL gestionado
D1 es SQLite gestionado por Cloudflare. Sin servidor que dimensionar, con migraciones, copias automáticas (Time Travel) y réplicas de lectura opcionales.
Crear la base de datos y las migraciones
npx wrangler d1 create notesnpx wrangler d1 migrations create notes create-notes{ "d1_databases": [ { "binding": "DB", "database_name": "notes", "database_id": "<id returned by the command>", "migrations_dir": "migrations" } ]}CREATE TABLE notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')));
CREATE INDEX notes_by_user ON notes (user_id, created_at);npx wrangler d1 migrations apply notes --localnpx wrangler d1 migrations apply notes --remoteConsultar desde el Worker
export interface Note { id: number; title: string; content: string; created_at: string;}
export async function listNotes(db: D1Database, userId: string) { const { results } = await db .prepare( "SELECT id, title, content, created_at FROM notes " + "WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", ) .bind(userId) .all<Note>(); return results;}
export async function createNote( db: D1Database, userId: string, title: string, content: string,) { return db .prepare( "INSERT INTO notes (user_id, title, content) " + "VALUES (?, ?, ?) RETURNING id", ) .bind(userId, title, content) .first<{ id: number }>();}Para varias escrituras que deben ir juntas, db.batch([...]) las ejecuta en una transacción.
Las migraciones en las pruebas
Las pruebas del capítulo de Workers arrancan con una base de datos vacía. Para que tengan las tablas, leo las migraciones al cargar la configuración y las aplico antes de cada archivo de pruebas:
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";import { defineConfig } from "vitest/config";
const migrations = await readD1Migrations("./migrations");
export default defineConfig({ plugins: [ cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" }, remoteBindings: false, miniflare: { bindings: { MIGRATIONS: migrations } }, }), ], test: { setupFiles: ["./test/setup.ts"] },});import { applyD1Migrations } from "cloudflare:test";import { env } from "cloudflare:workers";
await applyD1Migrations(env.DB, env.MIGRATIONS);/// <reference types="@cloudflare/vitest-plugin/types" />declare namespace Cloudflare { interface Env { MIGRATIONS: import("cloudflare:test").D1Migration[]; }}applyD1Migrations solo aplica las que faltan, así que no importa que se ejecute varias veces.
Un ORM, si lo prefieres
D1 funciona con Drizzle ORM y Kysely, que dan tipos a las tablas y generan migraciones. En proyectos pequeños me basta con SQL y una interfaz por tabla; en cuanto hay más de cinco o seis tablas, Drizzle me ahorra errores.
Copias y vuelta atrás
D1 guarda un historial (Time Travel) de 30 días en pago y 7 en gratuito. Si una migración borra lo que no debía:
npx wrangler d1 time-travel restore notes --timestamp=2026-12-20T10:00:00ZLímites de D1
| Límite | Gratuito | Pago |
|---|---|---|
| Tamaño por base de datos | 500 MB | 10 GB |
| Almacenamiento por cuenta | 5 GB | 1 TB |
| Consultas por invocación del Worker | 50 | 1.000 |
| Tamaño de fila, texto o BLOB | 2 MB | 2 MB |
Si te acercas a los 10 GB, hay dos salidas: una base de datos por cliente (D1 permite muchas y se cobran a partir de 100.000) o Postgres con Hyperdrive.
KV: lecturas masivas con consistencia eventual
KV guarda pares clave-valor replicados por toda la red. Las lecturas son muy rápidas; las escrituras tardan hasta unos 60 segundos en verse en todas partes. Lo uso para configuración, feature flags sencillos y caché de resultados caros.
{ "kv_namespaces": [{ "binding": "CONFIG", "id": "<id>" }]}export async function withCache<T>( kv: KVNamespace, key: string, seconds: number, compute: () => Promise<T>,): Promise<T> { const cached = await kv.get<T>(key, "json"); if (cached !== null) return cached; const value = await compute(); await kv.put(key, JSON.stringify(value), { expirationTtl: seconds }); return value;}Durable Objects: una base de datos por entidad
Un Durable Object es una instancia con identidad propia (una por usuario, sala o documento) que vive en un solo sitio, procesa las peticiones de una en una y tiene su propia base de datos SQLite. Es la pieza para coordinar: chats, documentos colaborativos, reservas sin dobles asignaciones o límites por usuario.
import { DurableObject } from "cloudflare:workers";
export class Room extends DurableObject<Env> { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, text TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) )`); }
async post(author: string, text: string) { this.ctx.storage.sql.exec( "INSERT INTO messages (author, text) VALUES (?, ?)", author, text, ); }
async latest(limit = 20) { return this.ctx.storage.sql .exec("SELECT author, text, created_at FROM messages ORDER BY id DESC LIMIT ?", limit) .toArray(); }}{ "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]}export { Room } from "./room";
app.post("/api/rooms/:id/messages", async (c) => { const room = c.env.ROOMS.getByName(c.req.param("id")); const { author, text } = await c.req.json<{ author: string; text: string }>(); await room.post(author, text); return c.json({ ok: true }, 201);});Los métodos públicos del Durable Object se llaman directamente desde el Worker (RPC), con tipos.
Fuera de Cloudflare: Supabase, Neon, PlanetScale, Turso o Aurora
Me quedo en D1 mientras puedo: es lo más sencillo y está a un binding de distancia. Me voy fuera cuando necesito una de estas tres cosas:
- Más de 10 GB en una sola base de datos que no se puede repartir por cliente.
- Algo propio de Postgres: extensiones como pgvector o PostGIS, o una herramienta que lo da por hecho.
- Algo que ya viene resuelto, como los usuarios y contraseñas de Supabase.
Así llega el Worker a cada una:
flowchart LR
W[Tu Worker] -->|binding| D1[(D1)]
W -->|binding| HD[Hyperdrive<br/>conexiones abiertas<br/>y caché de lecturas]
HD --> PG[(Supabase · Neon<br/>PlanetScale · Aurora)]
W -->|HTTPS| TU[(Turso)]
Y así elijo entre ellas. Es mi criterio, no una regla: si ya conoces bien una, probablemente te compense quedarte con ella.
flowchart TD
A[Necesito salir de D1] --> B{¿El resto del sistema<br/>ya está en AWS?}
B -->|Sí| AU[Aurora PostgreSQL]
B -->|No| C{¿Quiero usuarios y archivos<br/>ya resueltos?}
C -->|Sí| SB[Supabase]
C -->|No| D{¿Una base pequeña<br/>por cliente?}
D -->|Sí| TU[Turso]
D -->|No| E{¿Uso irregular y<br/>pagar solo lo que uso?}
E -->|Sí| NE[Neon]
E -->|No: carga constante o MySQL| PS[PlanetScale]
| D1 | Supabase | Neon | PlanetScale | Turso | Aurora PostgreSQL | |
|---|---|---|---|---|---|---|
| Motor | SQLite | Postgres | Postgres | Postgres o MySQL (Vitess) | SQLite (libSQL) | Postgres |
| Por qué la elegiría | Dentro de Cloudflare, sin conexiones | Trae usuarios, archivos y un panel | Se apaga sola y tiene ramas | Pensada para producción, con réplicas | Muchas bases pequeñas | El resto ya está en AWS |
| Desde un Worker | Binding | Hyperdrive (conexión directa) | Hyperdrive (sin pooling) | Hyperdrive | HTTPS con su cliente | Hyperdrive (acceso público o Tunnel) |
| Gratis | 500 MB por base, 5 GB en total | 500 MB y 2 proyectos; se pausa tras una semana sin uso | 1 GB por proyecto y 100 horas de cómputo al mes | No tiene | 5 GB y 100 bases | Cuentas nuevas: hasta 4 ACU y 1 GiB por clúster |
| Primer escalón de pago | Plan Workers (5 $/mes) | 25 $/mes | Por uso: 0,106 $ por hora de cómputo y 0,35 $ por GB | 5 $/mes (Postgres, un nodo); 39 $ (MySQL) | 4,99 $/mes | 0,12 $ por ACU-hora y 0,10 $ por GB (EE. UU.) |
Precios consultados el 4 de noviembre de 2026 en la página de cada proveedor.
Supabase
Postgres con usuarios, archivos y un panel para ver y editar los datos. Es la que recomiendo a quien empieza, o a quien viene de una herramienta de IA como Lovable, porque resuelve el inicio de sesión sin escribirlo.
- Para consultar la base desde el Worker, Hyperdrive con la cadena Direct connection del panel de Supabase, no la del pooler: Hyperdrive ya agrupa las conexiones.
- Para usuarios y archivos, su cliente (
@supabase/supabase-js), que va por HTTPS. - En el plan gratuito, el proyecto se pausa tras una semana sin uso. Para algo que tiene que estar siempre disponible, plan de pago.
Neon
Postgres que se apaga solo a los 5 minutos sin uso y cobra por hora de cómputo. Sus ramas (una copia de la base en segundos) me encajan para tener una base de datos por cada preview.
- Con Hyperdrive, la cadena sin pooling: en el panel de Neon, desmarca Connection pooling antes de copiarla.
- No uses el driver propio de Neon con Hyperdrive; usa
pgo Postgres.js. - Después de apagarse, la primera consulta tarda algo más en responder (un arranque en frío).
PlanetScale
Postgres y MySQL (Vitess) pensados para producción. No tiene plan gratuito: lo más barato es un Postgres de un solo nodo a 5 $ al mes, que yo usaría para prototipos y pruebas porque no tiene réplicas, o 15 $ con dos réplicas. Si necesitas MySQL, Hyperdrive también lo admite con el driver mysql2.
Turso
SQLite en la nube, pensado para tener muchas bases pequeñas (100 gratis). Me encaja para una base de datos por cliente fuera de Cloudflare. No pasa por Hyperdrive: se usa su cliente por HTTPS.
npm install @libsql/clientnpx wrangler secret put TURSO_TOKENimport { createClient } from "@libsql/client/web";
export function tursoClient(env: Env) { return createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN });}
export async function countNotes(env: Env) { const r = await tursoClient(env).execute("SELECT COUNT(*) AS n FROM notes"); return Number(r.rows[0]?.n ?? 0);}Aurora PostgreSQL, si todo está en AWS
Si el resto del sistema ya vive en AWS, prefiero Aurora PostgreSQL a RDS estándar por tres motivos:
- Aurora Serverless v2 puede bajar a 0 ACU cuando no hay uso, y entonces solo pagas el almacenamiento.
- El almacenamiento crece solo, sin elegir tamaño de disco.
- Las cuentas nuevas tienen un plan gratuito, de hasta 4 ACU y 1 GiB por clúster.
Para conectarla con Hyperdrive:
- La base tiene que aceptar conexiones de Cloudflare: acceso público con el grupo de seguridad abierto en el puerto 5432 (mejor limitado a las IP de Hyperdrive) o, si está en una red privada, Cloudflare Tunnel.
- Usa el endpoint de tipo Writer y un usuario con permisos limitados, no el maestro.
- Lo mismo vale para un Postgres en un servidor propio: con Tunnel no hace falta abrir puertos.
Conectar con Postgres mediante Hyperdrive
Para Supabase, Neon, PlanetScale o Aurora, Hyperdrive mantiene un grupo de conexiones cerca de tu base de datos y cachea las consultas de lectura. Sin Hyperdrive, cada petición abriría una conexión nueva desde la otra punta del mundo.
npx wrangler hyperdrive create notes-pg \ --connection-string="postgres://USER:PASSWORD@HOST:5432/notes"npm install pg # 8.16.3 or later, required by Hyperdrivenpm install -D @types/pg{ "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<id>" }]}import { Client } from "pg";
export async function recentNotes(env: Env) { // Hyperdrive reuses connections: opening a client per request is fine. const client = new Client({ connectionString: env.HYPERDRIVE.connectionString, }); await client.connect(); try { const { rows } = await client.query( "SELECT id, title FROM notes ORDER BY created_at DESC LIMIT 20", ); return rows; } finally { await client.end(); }}Lista de comprobación antes de producción
- Migraciones compatibles hacia atrás y aplicadas antes de desplegar el código.
- Una copia exportada antes de cada migración arriesgada (
wrangler d1 export, o la copia del proveedor). - Todas las consultas con
.bind()o parámetros, nunca texto concatenado. - En una base externa: un usuario con permisos mínimos, no el administrador.
- La cadena de conexión como secreto (
wrangler secret puto en Hyperdrive), nunca en el repositorio. - La región de la base de datos cerca de tus usuarios.
- Sabes cómo restaurar: lo has probado una vez, no solo leído.
Cuánto cuesta
Cuánto cuesta al mes
Supuestos: Un SaaS pequeño: D1 con 2 GB, 300 M de filas leídas y 5 M escritas; KV con 20 M de lecturas
| Concepto | Importe |
|---|---|
| D1: filas leídas25.000 M incluidas en pago | 0,00 $ |
| D1: filas escritas50 M incluidas | 0,00 $ |
| D1: almacenamiento5 GB incluidos | 0,00 $ |
| KV: lecturas10 M incluidas; 10 M más a 0,50 $ el millón | 5,00 $ |
| Total al mes | 5,00 $ |
Precios consultados el 3 de noviembre de 2026. Pueden cambiar.
Precios de referencia del plan de pago (noviembre de 2026):
| Producto | Incluido al mes | Después |
|---|---|---|
| D1: filas leídas | 25.000 millones | 0,001 $ por millón |
| D1: filas escritas | 50 millones | 1 $ por millón |
| D1: almacenamiento | 5 GB | 0,75 $ por GB |
| KV: lecturas / escrituras | 10 M / 1 M | 0,50 $ / 5 $ por millón |
| Durable Objects: peticiones | 1 millón | 0,15 $ por millón |
| Durable Objects: duración | 400.000 GB-s | 12,50 $ por millón de GB-s |
| Hyperdrive | Consultas ilimitadas en pago (100.000 al día en gratuito) | — |
Resumen
- D1 para casi todo lo relacional; KV para lo que se lee mucho y cambia poco.
- Durable Objects cuando necesitas estado y coordinación por entidad.
- Fuera, mi orden de preferencia depende del motivo: Supabase si quiero usuarios resueltos, Neon para pagar por uso, PlanetScale para carga constante o MySQL, Turso para muchas bases pequeñas y Aurora PostgreSQL si todo está en AWS.
- Hyperdrive para usar Postgres o MySQL externos sin pagar la latencia de conexión.
- Siguiente capítulo: archivos con R2.
