CloudflareMódulo II · Datos y archivos · Capítulo 5 de 15Nivel: BásicoEn desarrollo

5. Bases de datos

Qué base de datos uso con Cloudflare y por qué: D1, KV y Durable Objects dentro; Supabase, Neon, PlanetScale, Turso o Aurora PostgreSQL fuera.

En desarrollo. La publico antes de terminarla porque ya puede servirte; la iré completando y anotaré los cambios al final.

Víctor Rodríguez10 min de lectura

Probado conTypeScript 7.0Wrangler 4.157Vitest 5.1

Bases de datos de distintos tamaños conectadas a un mismo Worker.

Qué vas a aprender

  • Dónde guardar los datos de tu aplicación para no perderlos.
  • Cuándo me quedo en Cloudflare y cuándo uso una base de datos de fuera (Supabase, Neon…).
  • Crear tus tablas y cambiarlas sin romper nada.
  • Lo que reviso antes de abrir la aplicación al público.

Conocimientos iniciales

Si alguna de estas preguntas no te suena, ábrela: está explicada desde cero.

  • ¿Qué es una base de datos?

    El sitio donde tu aplicación guarda la información para no perderla y compartirla entre usuarios y dispositivos: usuarios, notas, pedidos. Es como una libreta compartida en la nube.

  • ¿Qué es SQL?

    El lenguaje para pedir y guardar datos en una base de datos de tablas: SELECT para leer, INSERT para añadir, UPDATE para cambiar. Postgres, MySQL y SQLite lo hablan.

  • ¿Qué es una migración?

    Un archivo con los cambios en la estructura de la base de datos (crear una tabla, añadir una columna). Se aplican en orden y quedan registradas, para que todos los entornos tengan la misma estructura.

  • ¿Qué es un almacén clave-valor?

    Una base de datos muy simple: guardas un valor con un nombre (la clave) y lo recuperas por ese nombre. Muy rápida para leer, pero sin consultas como las de SQL.

  • ¿Qué es un Durable Object?

    Un pequeño programa con memoria y base de datos propias, uno por cada cosa que lo necesite (una sala de chat, un documento, un usuario). Atiende sus peticiones de una en una, así nunca hay dos cambios a la vez sobre lo mismo.

  • ¿Qué es un binding?Repaso

    La conexión entre tu Worker y otro servicio de Cloudflare (una base de datos, un almacén de archivos). Se declara en wrangler.jsonc y aparece en tu código como env.NOMBRE, sin contraseñas ni direcciones.

    Lo vimos en el capítulo 3.

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

Cuál elegir
Si necesitas…Elige
Datos relacionales con consultas, hasta 10 GB por base de datosD1
Configuración, sesiones o caché que se leen mucho y cambian pocoConsistencia eventual: un cambio tarda hasta ~60 s en verse en todo el mundoKV
Estado por usuario, sala o documento, con coordinación o tiempo realDurable Objects
Base de datos grande, ya existente o con extensiones de PostgresHyperdrive + 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

Terminal
npx wrangler d1 create notes
npx wrangler d1 migrations create notes create-notes
wrangler.jsonc (extracto)
{
"d1_databases": [
{
"binding": "DB",
"database_name": "notes",
"database_id": "<id returned by the command>",
"migrations_dir": "migrations"
}
]
}
migrations/0001_create-notes.sql
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);
Terminal
npx wrangler d1 migrations apply notes --local
npx wrangler d1 migrations apply notes --remote

Consultar desde el Worker

src/notes.ts
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:

vitest.config.ts
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"] },
});
test/setup.ts
import { applyD1Migrations } from "cloudflare:test";
import { env } from "cloudflare:workers";
await applyD1Migrations(env.DB, env.MIGRATIONS);
test/env.d.ts
/// <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:

Terminal
npx wrangler d1 time-travel restore notes --timestamp=2026-12-20T10:00:00Z

Lí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.

wrangler.jsonc (extracto)
{
"kv_namespaces": [{ "binding": "CONFIG", "id": "<id>" }]
}
src/cache.ts
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.

src/room.ts
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();
}
}
wrangler.jsonc (extracto)
{
"durable_objects": {
"bindings": [{ "name": "ROOMS", "class_name": "Room" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]
}
src/index.ts (extracto)
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:

  1. Más de 10 GB en una sola base de datos que no se puede repartir por cliente.
  2. Algo propio de Postgres: extensiones como pgvector o PostGIS, o una herramienta que lo da por hecho.
  3. 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 pg o 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.

Terminal
npm install @libsql/client
npx wrangler secret put TURSO_TOKEN
src/turso.ts
import { 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.

Terminal
npx wrangler hyperdrive create notes-pg \
--connection-string="postgres://USER:PASSWORD@HOST:5432/notes"
npm install pg # 8.16.3 or later, required by Hyperdrive
npm install -D @types/pg
wrangler.jsonc (extracto)
{
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<id>" }]
}
src/pg.ts
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 put o 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

ConceptoImporte
D1: filas leídas25.000 M incluidas en pago0,00 $
D1: filas escritas50 M incluidas0,00 $
D1: almacenamiento5 GB incluidos0,00 $
KV: lecturas10 M incluidas; 10 M más a 0,50 $ el millón5,00 $
Total al mes5,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.

Recursos

¿Te ha servido? Esto forma parte de un libro que estoy escribiendo: Cloudflare de cero a producción. Ver el índice provisional →