> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Fastify a fondo: el framework rápido de Node.js, explicado por dentro y con código probado
- URL: https://blog.devutilites.com/fastify-a-fondo/
- Published: 2026-09-29T18:33:35.000Z
- Updated: 2026-09-29T18:33:35.000Z
- Description: Router radix, JSON Schema con Ajv, serialización compilada, hooks, encapsulación de plugins, TypeScript con TypeBox, seguridad, tests y despliegue: todo Fastify 5 con ejemplos probados.
- Author: Luis Collantes
- Tags: Backend

Si llevas un tiempo con Node.js, casi seguro que empezaste con Express. Es lo que sale en todos los tutoriales, funciona y hay respuestas para todo. Pero llega un día en que tu API crece y empiezas a echar cosas en falta: validar lo que entra sin escribir cien `if`, no filtrar por error la contraseña de un usuario en una respuesta, tener logs decentes, documentar la API sin mantener un fichero aparte, que el código no se convierta en un plato de espaguetis de middlewares… y, de paso, que vaya rápido.

**Fastify** nació exactamente para eso. Lo crearon en 2016 Matteo Collina y Tomas Della Vedova (los dos con mucho peso en el propio Node.js), hoy es un proyecto de la **OpenJS Foundation** y lo usan en producción empresas de todos los tamaños. Su lema es "rápido y con poca sobrecarga", pero en mi experiencia lo mejor de Fastify no es la velocidad: es que te obliga, con suavidad, a hacer las cosas bien.

Este artículo es largo a propósito. No me quiero quedar en el "hola mundo": vamos a ver **cómo funciona por dentro** (el router, la validación, la serialización, el ciclo de vida, la encapsulación), y después montaremos una API completa en TypeScript con validación, documentación OpenAPI, seguridad, base de datos, tests y despliegue en Docker/Coolify. **Todo el código que verás lo he ejecutado de verdad** en mi servidor con Fastify 5.12.5, Node.js 20.20 y 24.21, y TypeScript 7.0.2; las salidas que enseño son las reales.

📦

****Versiones probadas (29-sep-2026):** fastify 5.12.5 · @fastify/cors 11.3.0 · @fastify/helmet 13.1.1 · @fastify/rate-limit 11.2.0 · @fastify/jwt 10.2.2 · @fastify/swagger 9.9.0 · @fastify/swagger-ui 6.1.1 · @fastify/postgres 6.1.0 · @fastify/type-provider-typebox 6.1.0 · typebox 1.3.34 · Node.js 20.20.2 y 24.21.0.

## Qué es Fastify (y qué no es)

Fastify es un **framework web para Node.js**: recibe peticiones HTTP, decide qué función las atiende y te ayuda a construir la respuesta. Está en la misma liga que Express, Koa, Hono o el motor HTTP de NestJS (de hecho, NestJS puede usar Fastify por debajo en lugar de Express). No es un framework "con baterías" como NestJS o AdonisJS: no te impone controladores, inyección de dependencias ni ORM. Pero tampoco es tan minimalista como Express: trae de serie validación, serialización, logging, un sistema de plugins serio y un modo de probar la aplicación sin abrir puertos.

Sus principios de diseño, tal y como los define el propio proyecto, son:

- **Rendimiento:** poca sobrecarga por petición. Es de los frameworks más rápidos de Node.js.
- **Extensible:** todo se construye con hooks, plugins y decoradores.
- **Basado en schemas:** JSON Schema para validar lo que entra y para serializar lo que sale.
- **Logging:** los logs son críticos, pero caros; por eso usa pino, el logger más rápido del ecosistema.
- **Amigable para el desarrollador:** expresivo sin sacrificar rendimiento ni seguridad.
- **TypeScript:** los tipos vienen incluidos en el paquete y se mantienen al día.

### Sus características, de un vistazo

| Característica                 | Qué te da                                                                                                | Pieza por dentro                |
| ------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------- |
| **Router muy rápido**          | Rutas con parámetros, comodines, regex, opcionales, restricciones por versión o host                     | find-my-way (árbol radix)       |
| **Validación con JSON Schema** | body, query, params y headers validados antes del handler, con valores por defecto y conversión de tipos | Ajv 8 + ajv-formats             |
| **Serialización compilada**    | Respuestas filtradas por schema (no se escapan campos) y más rápidas                                     | fast-json-stringify             |
| **Hooks**                      | Engancharte a cualquier punto del ciclo de vida: 10 hooks de petición y 6 de aplicación                  | núcleo de Fastify               |
| **Plugins encapsulados**       | Cada módulo tiene su propio contexto: decoradores, hooks y rutas aislados                                | avvio                           |
| **Decoradores**                | Extender app, request y reply de forma segura y rápida                                                   | núcleo de Fastify               |
| **Logging integrado**          | JSON estructurado, un id por petición, redacción de secretos                                             | pino                            |
| **Testing sin red**            | Peticiones simuladas con inject(), sin abrir puertos                                                     | light-my-request                |
| **TypeScript**                 | Tipos incluidos; con type providers, los tipos salen del schema                                          | TypeBox, json-schema-to-ts, Zod |
| **JSON seguro**                | Protección contra *prototype poisoning* al parsear                                                       | secure-json-parse               |
| **HTTP/2 y HTTPS**             | Con una opción al crear la instancia                                                                     | módulos http2/https de Node     |
| **Ecosistema**                 | Decenas de plugins oficiales @fastify/\* y cientos de la comunidad                                       | —                               |

## Descarga e instalación

Fastify se descarga desde npm como cualquier paquete de Node.js. Estos son los enlaces oficiales:

| Recurso                            | Enlace                                                                                                                                                                                      |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Paquete en npm (descarga)          | [npmjs.com/package/fastify](https://www.npmjs.com/package/fastify?ref=blog.devutilites.com)                                                                                                 |
| Código fuente                      | [github.com/fastify/fastify](https://github.com/fastify/fastify?ref=blog.devutilites.com)                                                                                                   |
| Versiones y notas de cambios       | [github.com/fastify/fastify/releases](https://github.com/fastify/fastify/releases?ref=blog.devutilites.com)                                                                                 |
| Web y documentación oficial        | [fastify.dev](https://fastify.dev/?ref=blog.devutilites.com) · [fastify.dev/docs/latest](https://fastify.dev/docs/latest/?ref=blog.devutilites.com)                                         |
| Generador de proyectos             | [create-fastify](https://www.npmjs.com/package/create-fastify?ref=blog.devutilites.com) (npm init fastify) y [fastify-cli](https://github.com/fastify/fastify-cli?ref=blog.devutilites.com) |
| Ecosistema de plugins              | [fastify.dev/ecosystem](https://fastify.dev/ecosystem/?ref=blog.devutilites.com)                                                                                                            |
| Node.js (requisito: 20 o superior) | [nodejs.org/es/download](https://nodejs.org/es/download?ref=blog.devutilites.com)                                                                                                           |

Necesitas **Node.js 20 o superior** (Fastify 5 dejó de soportar las versiones antiguas). Si tienes una versión vieja, instálate [nvm](https://github.com/nvm-sh/nvm?ref=blog.devutilites.com) o descarga el instalador de [nodejs.org](https://nodejs.org/es/download?ref=blog.devutilites.com). Luego, en una carpeta vacía:

```bash
# Proyecto vacío con Fastify (Node.js 20 o superior)
mkdir mi-api && cd mi-api
npm init -y
npm pkg set type=module      # usamos import/export
npm i fastify

# Comprueba la versión instalada
npm ls fastify
# mi-api@1.0.0
# └── fastify@5.12.5
```

### O con el generador oficial

Si prefieres partir de un esqueleto, el generador crea un proyecto con carga automática de plugins y rutas por carpetas, tests y scripts ya configurados. Lo he probado y los tres tests que trae pasan a la primera:

```bash
# Generador oficial (usa fastify-cli por debajo)
npm init fastify@latest mi-api            # JavaScript
npm init fastify@latest mi-api -- --lang=ts   # TypeScript

cd mi-api && npm install
npm run dev     # arranca con recarga automática y logs legibles
npm test        # node:test, ya trae 3 pruebas de ejemplo
```

```text
mi-api/
├── app.js               # registra @fastify/autoload para plugins/ y routes/
├── plugins/
│   ├── sensible.js      # @fastify/sensible: httpErrors, reply.notFound()...
│   └── support.js       # ejemplo de plugin compartido con fastify-plugin
├── routes/
│   ├── root.js          # GET /
│   └── example/
│       └── index.js     # GET /example  (la carpeta da el prefijo)
└── test/
```

Para aprender, sin embargo, te recomiendo empezar sin generador: así ves cada pieza. Es lo que haremos aquí.

### Versiones y soporte

| Versión                  | Publicada                        | Fin de soporte | Node.js                      |
| ------------------------ | -------------------------------- | -------------- | ---------------------------- |
| 3.x                      | julio 2020                       | junio 2023     | 10 – 18                      |
| 4.x                      | junio 2022                       | 30 junio 2025  | 14 – 22                      |
| **5.x (actual, 5.12.5)** | septiembre 2024                  | por definir    | 20, 22 (y 24 en la práctica) |
| 6.0 (alpha)              | en pruebas en septiembre de 2026 | —              | —                            |

Fastify publica versiones con mucha frecuencia y, ojo, **muchas son de seguridad**: en septiembre de 2026 salieron la 5.12.2 (cuatro avisos de seguridad) y la 5.12.5 (uno más). Mantén la dependencia al día con `npm update` o Dependabot. La rama 6 ya está en alpha; si empiezas hoy, usa la 5.

## Tu primer servidor: el ejemplo pequeño

Este es el "hola mundo" completo. Guárdalo como `hola.js`:

```javascript
// hola.js
import Fastify from 'fastify'

const app = Fastify({ logger: true })

app.get('/', async (request, reply) => {
  return { hola: 'mundo' }
})

try {
  await app.listen({ port: 3000, host: '0.0.0.0' })
} catch (err) {
  app.log.error(err)
  process.exit(1)
}
```

```bash
$ node hola.js
{"level":30,"time":1790703721144,"pid":1997287,"msg":"Server listening at http://127.0.0.1:3000"}

$ curl -s localhost:3000/
{"hola":"mundo"}

# y en el log del servidor, una línea por petición, en JSON:
{"level":30,"reqId":"req-1","req":{"method":"GET","url":"/","host":"localhost:3000",...},"msg":"incoming request"}
{"level":30,"reqId":"req-1","res":{"statusCode":200},"responseTime":5.4,"msg":"request completed"}
```

Ya hay varias cosas que merecen explicación:

- **El handler devuelve un objeto** y Fastify lo convierte a JSON y pone la cabecera `content-type: application/json`. Con funciones `async`, lo que devuelves es la respuesta. No hace falta `res.json()`.
- `**logger: true**` activa pino: cada petición genera dos líneas JSON (entrada y salida) con un `reqId` para seguirla.
- `**host: '0.0.0.0'**` es importante: por defecto Fastify escucha solo en `localhost`, que dentro de un contenedor Docker significa "inaccesible desde fuera". Volveremos a esto al desplegar.
- **`await app.listen()`** arranca el servidor cuando todos los plugins están cargados. Si algo falla al arrancar (un plugin roto, un puerto ocupado), la promesa se rechaza y el proceso sale con error, que es lo que quieres.
- Como ves, **Fastify también contesta a `HEAD`** automáticamente en cada ruta `GET`.

## Cómo funciona por dentro

Para usar bien Fastify hay que entender cinco piezas: el router, la validación, la serialización, el ciclo de vida con sus hooks y el sistema de plugins. Casi todos los "¿por qué no funciona esto?" se explican con alguna de ellas.

### 1\. El router: find-my-way y el árbol radix

Express guarda las rutas en una **lista** y, para cada petición, las recorre una a una probando expresiones regulares hasta encontrar la que encaja. Con diez rutas da igual; con trescientas, se nota.

Fastify usa [find-my-way](https://github.com/delvedor/find-my-way?ref=blog.devutilites.com), un router basado en un **árbol radix** (un árbol de prefijos comprimido). Las rutas `/usuarios`, `/usuarios/:id` y `/usuarios/:id/pedidos` comparten el prefijo `/usuarios`, que se guarda una sola vez. Buscar una ruta es bajar por el árbol carácter a carácter: el coste depende de la **longitud de la URL**, no del número de rutas. Además, find-my-way separa un árbol por método HTTP y prioriza siempre lo estático sobre lo paramétrico, y lo paramétrico sobre los comodines, así que el orden en que declares las rutas no importa (en Express sí).

Puedes ver el árbol con `app.printRoutes()`. Este ejemplo recoge casi todo lo que sabe hacer el router:

```javascript
// router.js: opciones de find-my-way
import Fastify from 'fastify'
const app = Fastify({
  routerOptions: {
    ignoreTrailingSlash: true,   // /usuarios y /usuarios/ son la misma ruta
    caseSensitive: false,        // /Usuarios == /usuarios
    maxParamLength: 200          // parámetros más largos -> 404 (defensa contra abusos)
  }
})
app.get('/usuarios', async () => ['ada', 'grace'])
app.get('/usuarios/:id(^\\d+)', async (req) => ({ numerico: req.params.id }))      // con regex
app.get('/usuarios/:nombre', async (req) => ({ porNombre: req.params.nombre }))
app.get('/fotos/:ancho-:alto.jpg', async (req) => req.params)                    // varios en un segmento
app.get('/archivo/:nombre?', async (req) => ({ nombre: req.params.nombre ?? '(ninguno)' })) // opcional
app.get('/api', { constraints: { version: '2.0.0' } }, async () => ({ v: 2 }))  // por cabecera Accept-Version
app.get('/api', async () => ({ v: 1 }))
await app.ready()
console.log(app.printRoutes({ commonPrefix: false }))
for (const [url, h] of [['/usuarios/'], ['/USUARIOS'], ['/usuarios/42'], ['/usuarios/ada'], ['/fotos/800-600.jpg'], ['/archivo'], ['/archivo/a.txt'], ['/api'], ['/api', { 'accept-version': '2.x' }]]) {
  const r = await app.inject({ url, headers: h }); console.log(url, h ? JSON.stringify(h) : '', '→', r.body)
}
```

```text
└── /
    ├── usuarios (GET, HEAD)
    │   ├── /:id(^\d+) (GET, HEAD)
    │   └── /:nombre (GET, HEAD)
    ...

/usuarios/          → ["ada","grace"]          (ignoreTrailingSlash)
/USUARIOS           → ["ada","grace"]          (caseSensitive: false)
/usuarios/42        → {"numerico":"42"}        (gana la regex)
/usuarios/ada       → {"porNombre":"ada"}
/fotos/800-600.jpg  → {"ancho":"800","alto":"600"}
/archivo            → {"nombre":"(ninguno)"}
/api                → {"v":1}
/api  (Accept-Version: 2.x) → {"v":2}
```

💡

Desde Fastify 5 las opciones del router van dentro de **`routerOptions`**. Si las pones en la raíz (`ignoreTrailingSlash: true` a secas), sigue funcionando pero verás el aviso de obsolescencia ****FSTDEP022**: la versión 6 las eliminará.

### 2\. Validación: JSON Schema y Ajv

Cada ruta puede declarar un `schema` con cuatro partes que se validan **antes** de que tu código se ejecute: `params`, `querystring`, `body` y `headers`. El formato es [JSON Schema](https://json-schema.org/?ref=blog.devutilites.com), un estándar, y el validador es [Ajv](https://ajv.js.org/?ref=blog.devutilites.com), el más rápido que existe para JavaScript.

El truco del rendimiento está en que Ajv **no interpreta** el schema en cada petición: al arrancar, lo **compila** a una función JavaScript hecha a medida (literalmente genera código con `new Function`). Validar un body es ejecutar esa función, sin recorrer el schema. Por eso Fastify compila todos los schemas en el arranque: si tienes un error en uno, lo sabrás al iniciar, no con la primera petición.

Veamos rutas con todo tipo de validación, incluido un schema reutilizable con `$ref`:

```javascript
// rutas.js: parámetros, query string, body y respuesta, todo con JSON Schema
import Fastify from 'fastify'

const app = Fastify({ logger: false })

// Esquema reutilizable: se registra una vez y se referencia con $ref
app.addSchema({
  $id: 'usuario',
  type: 'object',
  properties: {
    id: { type: 'integer' },
    nombre: { type: 'string' },
    email: { type: 'string', format: 'email' }
  }
})

// Ruta con parámetro y validación: :id tiene que ser un entero
app.get('/usuarios/:id', {
  schema: {
    params: {
      type: 'object',
      properties: { id: { type: 'integer', minimum: 1 } },
      required: ['id']
    },
    response: {
      200: { $ref: 'usuario#' }
    }
  }
}, async (request) => {
  // request.params.id ya llega convertido a número (coerceTypes de Ajv)
  return {
    id: request.params.id,
    nombre: 'Ada',
    email: 'ada@example.com',
    password: 'esto-nunca-sale' // no está en el schema de respuesta: se descarta
  }
})

// Query string con valores por defecto
app.get('/buscar', {
  schema: {
    querystring: {
      type: 'object',
      properties: {
        q: { type: 'string', minLength: 2 },
        pagina: { type: 'integer', minimum: 1, default: 1 },
        porPagina: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
      },
      required: ['q']
    }
  }
}, async (request) => {
  const { q, pagina, porPagina } = request.query
  return { q, pagina, porPagina }
})

// Body validado: si falta algo, Fastify responde 400 sin llegar al handler
app.post('/usuarios', {
  schema: {
    body: {
      type: 'object',
      properties: {
        nombre: { type: 'string', minLength: 1, maxLength: 80 },
        email: { type: 'string', format: 'email' }
      },
      required: ['nombre', 'email'],
      additionalProperties: false
    },
    response: {
      201: { $ref: 'usuario#' }
    }
  }
}, async (request, reply) => {
  reply.code(201)
  return { id: 42, ...request.body }
})

// Rutas con comodín y con varios parámetros
app.get('/ficheros/*', async (request) => ({ ruta: request.params['*'] }))
app.get('/tiendas/:tienda/productos/:sku', async (request) => request.params)

// La forma "completa" con app.route()
app.route({
  method: ['GET', 'HEAD'],
  url: '/salud',
  handler: async () => ({ ok: true })
})

await app.listen({ port: 3000, host: '0.0.0.0' })
```

```bash
$ curl -s localhost:3000/usuarios/7
{"id":7,"nombre":"Ada","email":"ada@example.com"}            # ¡sin password!

$ curl -s localhost:3000/usuarios/abc
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"params/id must be integer"}

$ curl -s "localhost:3000/buscar?q=fastify"
{"q":"fastify","pagina":1,"porPagina":20}                     # valores por defecto aplicados

$ curl -s "localhost:3000/buscar?q=f"
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"querystring/q must NOT have fewer than 2 characters"}

$ curl -s -XPOST localhost:3000/usuarios -H 'content-type: application/json' \
    -d '{"nombre":"","email":"no-es-email","admin":true}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/nombre must NOT have fewer than 1 characters"}

$ curl -s localhost:3000/ficheros/a/b/c.txt
{"ruta":"a/b/c.txt"}
```

Fíjate en tres detalles que sorprenden la primera vez:

1. **`/usuarios/7` devuelve `id: 7` como número**, aunque en la URL todo es texto. Fastify configura Ajv con `coerceTypes: 'array'`: convierte tipos cuando puede ("7" → 7, "true" → true) y convierte un valor suelto en array si el schema pide un array (muy útil en query strings como `?tag=a`).
2. **Los valores por defecto se aplican** (`useDefaults: true`): `pagina` y `porPagina` aparecen aunque no los mandes.
3. **`"admin": true` no provoca un error**: con `removeAdditional: true`, Ajv borra en silencio las propiedades que no están en el schema cuando pones `additionalProperties: false`. El error que ves en la última petición es por `nombre` vacío, no por `admin`.

Estos valores por defecto son cómodos, pero conviene conocerlos. Así se comportan, probado:

```javascript
// ajv.js: Ajv más estricto
import Fastify from 'fastify'

const app = Fastify({
  ajv: {
    customOptions: {
      removeAdditional: false, // campos de más -> 400 en vez de borrarlos en silencio
      coerceTypes: false,      // "12" ya no se convierte en 12 (ojo: afecta a params y query)
      allErrors: true          // todos los errores a la vez (Fastify lo trae apagado: con schemas
                               // mal acotados, un atacante puede forzar mucho trabajo a Ajv)
    }
  }
})

app.post('/u', {
  schema: {
    body: {
      type: 'object',
      required: ['nombre', 'edad'],
      properties: { nombre: { type: 'string', minLength: 2 }, edad: { type: 'integer' } },
      additionalProperties: false
    }
  }
}, async (request) => request.body)

const r = await app.inject({ method: 'POST', url: '/u', payload: { nombre: 'A', edad: '30', admin: true } })
console.log(r.statusCode, r.json().message)
```

```text
// Con la configuración por defecto de Fastify:
POST /u  {"nombre":"Ada","admin":true}   →  200 {"nombre":"Ada"}   // "admin" se borra en silencio
POST /u  {"nombre":12}                   →  200 {"nombre":"12"}    // 12 se convierte en "12"

// Con el Ajv estricto de arriba:
400 body must NOT have additional properties, body/nombre must NOT have fewer than 2 characters, body/edad must be integer
```

⚠️

****Cuidado con** **`allErrors: true`** **.** Fastify lo trae desactivado a propósito: con schemas poco acotados (cadenas sin `maxLength`, arrays sin `maxItems`), un atacante puede enviar un body enorme y lleno de errores para obligar a Ajv a trabajar mucho. Si lo activas, pon límites a todo.

### 3\. Serialización: fast-json-stringify

La otra mitad del schema es `response`: describe lo que devuelve cada código de estado. Fastify lo usa para compilar, con [fast-json-stringify](https://github.com/fastify/fast-json-stringify?ref=blog.devutilites.com), una función que convierte tu objeto en JSON **sabiendo de antemano su forma**. `JSON.stringify` tiene que inspeccionar cada valor para averiguar su tipo; la función generada ya sabe que `id` es un entero y `nombre` una cadena.

Esto tiene dos consecuencias, y la segunda es la importante:

- **Rendimiento:** serializar es más rápido.
- **Seguridad:** **solo se envían los campos declarados**. En el ejemplo de rutas, el handler devolvía `password`, y no salió en la respuesta. Es la mejor protección que conozco contra la filtración accidental de datos: da igual que alguien haga `return usuario` con el objeto entero de la base de datos.

Quise medir la ganancia de rendimiento en mi servidor en lugar de repetir lo que dice la documentación. Primero, un micro-benchmark sin red (serializar el mismo objeto millones de veces):

```javascript
// micro-benchmark sin red: JSON.stringify frente a la función generada por fast-json-stringify
import fjs from 'fast-json-stringify'
const obj = { id: 1, nombre: 'Producto 1', precio: 9.99, stock: 3, etiquetas: ['oferta', 'nuevo'] }
const lista = Array.from({ length: 50 }, (_, i) => ({ ...obj, id: i }))
const item = { type: 'object', properties: { id: { type: 'integer' }, nombre: { type: 'string' }, precio: { type: 'number' }, stock: { type: 'integer' }, etiquetas: { type: 'array', items: { type: 'string' } } } }
const uno = fjs(item), varios = fjs({ type: 'array', items: item })
function medir (nombre, fn, n) {
  for (let i = 0; i < n / 10; i++) fn()
  const t = process.hrtime.bigint(); for (let i = 0; i < n; i++) fn()
  const ns = Number(process.hrtime.bigint() - t) / n
  console.log(nombre.padEnd(34), (1e9 / ns / 1e6).toFixed(2), 'M ops/s')
}
medir('1 objeto  · JSON.stringify', () => JSON.stringify(obj), 2e6)
medir('1 objeto  · fast-json-stringify', () => uno(obj), 2e6)
medir('50 objetos · JSON.stringify', () => JSON.stringify(lista), 1e5)
medir('50 objetos · fast-json-stringify', () => varios(lista), 1e5)
```

```text
Node v24.21.0
1 objeto   · JSON.stringify          1.48 M ops/s
1 objeto   · fast-json-stringify     2.88 M ops/s
50 objetos · JSON.stringify          0.04 M ops/s
50 objetos · fast-json-stringify     0.05 M ops/s
```

Con un objeto, la función compilada es casi **el doble de rápida**; con una lista de 50 objetos, la ventaja baja a un 25 % aproximadamente. Luego lo medí de extremo a extremo, con [autocannon](https://github.com/mcollina/autocannon?ref=blog.devutilites.com), 50 conexiones y la misma lista de 50 productos, con y sin schema de respuesta, varias veces alternando:

```javascript
// serializacion.js: la misma respuesta, con y sin schema de respuesta
import Fastify from 'fastify'

const app = Fastify({ logger: false })

const productos = Array.from({ length: 50 }, (_, i) => ({
  id: i + 1,
  nombre: `Producto ${i + 1}`,
  precio: 9.99 + i,
  stock: i * 3,
  etiquetas: ['oferta', 'nuevo'],
  costeInterno: 4.2 // dato que no debería salir nunca
}))

// Sin schema: JSON.stringify de todo el objeto (incluido costeInterno)
app.get('/sin-schema', async () => productos)

// Con schema: fast-json-stringify genera una función a medida
app.get('/con-schema', {
  schema: {
    response: {
      200: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            id: { type: 'integer' },
            nombre: { type: 'string' },
            precio: { type: 'number' },
            stock: { type: 'integer' },
            etiquetas: { type: 'array', items: { type: 'string' } }
          }
        }
      }
    }
  }
}, async () => productos)

await app.listen({ port: 3000, host: '0.0.0.0' })
```

Resultado honesto: **en HTTP, la diferencia se quedó dentro del ruido** (entre 7.000 y 9.700 peticiones por segundo en ambos casos, en un VPS de 4 núcleos que además estaba ejecutando otras cosas). Cuando la petición completa incluye red, parseo de cabeceras y el resto del ciclo, serializar es una parte pequeña del coste. Mi conclusión: pon schemas de respuesta **por seguridad y por documentación**; la velocidad es un extra que notarás sobre todo en respuestas pequeñas y muy frecuentes.

### 4\. El ciclo de vida y los hooks

Cada petición recorre siempre los mismos pasos, y en muchos de ellos puedes engancharte con un **hook**. Este diagrama es el mapa que conviene tener en la cabeza:

![Diagrama del ciclo de vida de una petición en Fastify: petición, router, onRequest, preParsing, parseo, preValidation, validación, preHandler, handler, preSerialization, serialización, onSend, respuesta y onResponse, con la rama de errores](https://blog.devutilites.com/content/images/2026/09/fastify-ciclo.png)

Los pasos de Fastify (gris), los hooks donde puedes meter tu código (índigo) y tu handler (verde). Diagrama: devutilites.

| Hook                                                      | Cuándo                                   | Para qué lo uso                                           |
| --------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------- |
| onRequest                                                 | Nada más llegar; sin body                | Autenticación por cabecera, bloquear IPs, medir tiempos   |
| preParsing                                                | Antes de leer el body                    | Descomprimir o transformar el stream                      |
| preValidation                                             | Body leído, sin validar                  | Normalizar datos (trim, minúsculas)                       |
| preHandler                                                | Todo validado                            | Autorización, cargar el usuario de la base de datos       |
| preSerialization                                          | Handler terminado, objeto sin serializar | Envolver respuestas ({ data: ... })                       |
| onSend                                                    | Payload ya es texto/Buffer/stream        | Cabeceras finales, comprimir, firmar                      |
| onResponse                                                | Respuesta enviada                        | Métricas, auditoría                                       |
| onError                                                   | Cuando algo lanza                        | Mandar el error a Sentry (no cambia la respuesta)         |
| onTimeout / onRequestAbort                                | Timeout del socket / el cliente se va    | Liberar recursos, registrar cancelaciones                 |
| onReady, onListen, onClose, preClose, onRoute, onRegister | Hooks de aplicación                      | Migraciones al arrancar, cerrar conexiones, auditar rutas |

Este ejemplo usa casi todos a la vez, para que veas en qué orden se ejecutan y qué puede hacer cada uno:

```javascript
// hooks.js: el ciclo de vida de una petición, paso a paso
import Fastify from 'fastify'

const app = Fastify({ logger: { level: 'info' } })

// 1. onRequest: lo primero que se ejecuta. Aún no hay body.
app.addHook('onRequest', async (request, reply) => {
  request.inicio = process.hrtime.bigint()
  if (request.headers['x-bloqueado'] === 'si') {
    // Responder desde un hook corta la cadena: el handler no se ejecuta
    return reply.code(403).send({ error: 'Prohibido' })
  }
})

// 2. preParsing / 3. preValidation: antes de parsear y validar el body
app.addHook('preValidation', async (request) => {
  if (request.body && typeof request.body.nombre === 'string') {
    request.body.nombre = request.body.nombre.trim()
  }
})

// 4. preHandler: ya está todo validado; el sitio típico para autorización
app.addHook('preHandler', async (request, reply) => {
  request.log.info({ ruta: request.routeOptions.url }, 'entrando al handler')
})

// 5. preSerialization: transforma el objeto antes de convertirlo a JSON
app.addHook('preSerialization', async (request, reply, payload) => {
  if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
    return { ...payload, servidoPor: 'fastify' }
  }
  return payload
})

// 6. onSend: ya es texto (o Buffer/stream). Útil para cabeceras finales
app.addHook('onSend', async (request, reply, payload) => {
  const ms = Number(process.hrtime.bigint() - request.inicio) / 1e6
  reply.header('x-tiempo-ms', ms.toFixed(2))
  return payload
})

// 7. onResponse: la respuesta ya se ha enviado. Métricas, auditoría...
app.addHook('onResponse', async (request, reply) => {
  request.log.info({ status: reply.statusCode, ms: reply.elapsedTime.toFixed(2) }, 'fin')
})

// onError: se ejecuta si algo lanza una excepción (solo para observar)
app.addHook('onError', async (request, reply, error) => {
  request.log.warn({ err: error.message }, 'algo ha fallado')
})

app.post('/saludo', {
  schema: {
    body: {
      type: 'object',
      properties: { nombre: { type: 'string', minLength: 1 } },
      required: ['nombre']
    }
  },
  // Los hooks también se pueden declarar solo para una ruta
  onRequest: async (request) => { request.log.info('hook exclusivo de /saludo') }
}, async (request) => {
  return { mensaje: `Hola, ${request.body.nombre}` }
})

app.get('/roto', async () => { throw new Error('ups') })

await app.listen({ port: 3000, host: '0.0.0.0' })
```

```bash
$ curl -si -XPOST localhost:3000/saludo -H 'content-type: application/json' -d '{"nombre":"   Linus   "}'
HTTP/1.1 200 OK
x-tiempo-ms: 7.77
{"mensaje":"Hola, Linus","servidoPor":"fastify"}      # trim() de preValidation + campo de preSerialization

$ curl -s -XPOST localhost:3000/saludo -H 'x-bloqueado: si' ...
{"error":"Prohibido","servidoPor":"fastify"}         # cortado en onRequest (y aun así pasa por preSerialization)

$ curl -s localhost:3000/roto
{"statusCode":500,"error":"Internal Server Error","message":"ups"}

# En el log, en orden: "hook exclusivo de /saludo" → "entrando al handler" → "fin" (status 200, 13 ms)
```

Reglas que conviene grabarse:

- Un hook **async** no recibe `done`; uno con callback, sí. **No mezcles los dos estilos** en la misma función: o `async` o `done()`.
- Si un hook responde (`return reply.send(...)`), la cadena se corta: el handler no se ejecuta, pero **los hooks de salida sí** (`preSerialization`, `onSend`, `onResponse`). Por eso la respuesta "Prohibido" llevaba `servidoPor`.
- `onError` **no puede cambiar la respuesta**: solo sirve para observar. Para dar forma al error se usa `setErrorHandler`.
- Los hooks se pueden declarar **para una sola ruta** (en sus opciones) o para un **plugin**; en ese caso solo afectan a las rutas de ese plugin. Esto nos lleva a la pieza más importante.

### 5\. Plugins, encapsulación y avvio

En Fastify **todo es un plugin**: tus rutas, tu conexión a la base de datos, tu autenticación. Un plugin es simplemente una función `async (instancia, opciones) => {}` que se registra con `app.register()`. Lo que lo hace especial es la **encapsulación**: cada `register()` crea un **contexto hijo** que hereda todo lo del padre (decoradores, hooks, schemas), pero lo que se añade dentro **no sube** al padre ni se ve desde los hermanos.

![Árbol de contextos de Fastify: la raíz con tres plugins hijos; el envuelto con fastify-plugin decora la raíz, los otros dos están aislados entre sí](https://blog.devutilites.com/content/images/2026/09/fastify-encapsulacion.png)

La herencia va de arriba abajo; fastify-plugin es la única forma de que algo "suba". Diagrama: devutilites.

```javascript
// encapsulacion.js: cada register() crea un contexto hijo
import Fastify from 'fastify'
import fp from 'fastify-plugin'

const app = Fastify()

// Plugin SIN fastify-plugin: lo que decore se queda dentro de su contexto
async function pluginPrivado (instancia, opts) {
  instancia.decorate('secreto', 'solo lo veo yo y mis hijos')
  instancia.get('/privado', async function () {
    return { secreto: this.secreto } // "this" es la instancia del contexto
  })
}

// Plugin CON fastify-plugin: "se salta" la encapsulación y decora al padre
const utilidades = fp(async function (instancia, opts) {
  instancia.decorate('formatearPrecio', (centimos) =>
    new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(centimos / 100))
}, { name: 'utilidades' })

app.register(utilidades)
app.register(pluginPrivado)

// Un plugin hermano con prefijo: ve formatearPrecio, pero no secreto
app.register(async function tienda (instancia) {
  instancia.get('/precio', async () => ({
    precio: instancia.formatearPrecio(1234567),
    veoElSecreto: instancia.hasDecorator('secreto')
  }))
}, { prefix: '/tienda' })

await app.ready()
console.log(app.printRoutes())
console.log(app.printPlugins())
for (const url of ['/privado', '/tienda/precio']) {
  const r = await app.inject(url)
  console.log(url, r.body)
}
console.log('raíz ve secreto:', app.hasDecorator('secreto'), '| raíz ve formatearPrecio:', app.hasDecorator('formatearPrecio'))
```

```text
$ node encapsulacion.js
└── /
    ├── privado (GET, HEAD)
    └── tienda/precio (GET, HEAD)

root 12 ms
├── utilidades 0 ms
├─┬ pluginPrivado 2 ms
└─┬ tienda 1 ms

/privado {"secreto":"solo lo veo yo y mis hijos"}
/tienda/precio {"precio":"12.345,67 €","veoElSecreto":false}
raíz ve secreto: false | raíz ve formatearPrecio: true
```

¿Por qué tanta ceremonia? Porque te permite, por ejemplo, poner un hook de autenticación solo en el plugin `/admin` sin tocar el resto de la API, o tener dos versiones de la API con decoradores distintos que no se pisan. Es lo que evita el problema clásico de Express, donde un `app.use()` mal colocado afecta a todas las rutas.

La carga la hace [avvio](https://github.com/fastify/avvio?ref=blog.devutilites.com), un cargador de plugins asíncrono que garantiza el **orden**: un plugin no empieza a cargarse hasta que el anterior ha terminado, y `ready()`/`listen()` esperan a que termine todo el árbol. `printPlugins()` te enseña ese árbol con el tiempo de carga de cada plugin, muy útil para encontrar el que tarda en arrancar.

🧩

****Cuándo usar** **`fastify-plugin`** **:** cuando el plugin existe para ****compartir** algo con el resto de la aplicación: una conexión a la base de datos, un decorador de utilidad, un hook global. ****Cuándo no:** en plugins de rutas; ahí quieres justo la encapsulación. Dale siempre un `name` para que los errores y `printPlugins()` sean legibles.

### 6\. Decoradores

Los decoradores son la forma oficial de añadir cosas a la instancia (`decorate`), a la petición (`decorateRequest`) y a la respuesta (`decorateReply`). ¿Por qué no hacer simplemente `request.usuario = ...`? Por rendimiento: V8 optimiza los objetos según su "forma" (sus propiedades). Si a veces añades `usuario` y a veces no, cada petición tiene una forma distinta y V8 deja de optimizar. `decorateRequest('usuario', null)` reserva la propiedad en el prototipo desde el principio, así que todas las peticiones tienen la misma forma.

Dos normas: el valor inicial de `decorateRequest` debe ser `null` o un tipo primitivo (nunca un objeto, que se compartiría entre todas las peticiones: Fastify te lo impide), y si necesitas un objeto por petición, créalo en un hook `onRequest`. Lo verás en acción en la sección de seguridad.

### 7\. Logging con pino

Fastify trae [pino](https://getpino.io/?ref=blog.devutilites.com) integrado: escribe JSON, una línea por evento, y es muy rápido porque delega el formateo "bonito" a otro proceso. `request.log` es un logger hijo que añade el `reqId` automáticamente, así que puedes seguir todas las líneas de una misma petición. Una configuración que uso en producción:

```javascript
// logs.js: pino, el logger integrado
import Fastify, { LogController } from 'fastify'
import { randomUUID } from 'node:crypto'

const app = Fastify({
  logger: {
    level: process.env.LOG_LEVEL ?? 'info',
    // Nunca escribas contraseñas ni tokens en los logs
    redact: ['req.headers.authorization', 'req.headers.cookie', 'body.clave'],
    serializers: {
      // Por defecto se registran método, url, host, IP...; aquí añadimos el user-agent
      req (request) {
        return { method: request.method, url: request.url, ua: request.headers['user-agent'] }
      }
    }
  },
  // Reutiliza el id que manda el proxy (o genera uno): así se siguen las peticiones entre servicios
  requestIdHeader: 'x-request-id',
  genReqId: () => randomUUID(),
  // Fastify 5.12: el registro de cada petición se controla con un LogController
  logController: new LogController({
    disableRequestLogging: (request) => request.url === '/salud' // el health check no ensucia los logs
  })
})

app.get('/salud', async () => ({ ok: true }))

app.post('/login', async (request) => {
  // request.log es un "hijo" que añade reqId a cada línea automáticamente
  request.log.info({ body: request.body }, 'intento de login')
  const log = request.log.child({ modulo: 'auth' })
  log.debug('esto solo sale con LOG_LEVEL=debug')
  log.warn({ usuario: request.body.usuario }, 'contraseña incorrecta')
  return { ok: false }
})

await app.inject('/salud')
await app.inject({
  method: 'POST', url: '/login',
  headers: { authorization: 'Bearer secreto', 'user-agent': 'curl/8.5', 'x-request-id': 'abc-123' },
  payload: { usuario: 'ada', clave: 'lovelace' }
})
```

```text
{"level":30,"reqId":"abc-123","req":{"method":"POST","url":"/login","ua":"curl/8.5"},"msg":"incoming request"}
{"level":30,"reqId":"abc-123","body":{"usuario":"ada","clave":"[Redacted]"},"msg":"intento de login"}
{"level":40,"reqId":"abc-123","modulo":"auth","usuario":"ada","msg":"contraseña incorrecta"}
{"level":30,"reqId":"abc-123","res":{"statusCode":200},"responseTime":8.9,"msg":"request completed"}
// La petición a /salud no aparece: la filtra el LogController
```

- `**redact**` sustituye por `[Redacted]` las rutas que indiques: cabeceras de autenticación, cookies, contraseñas. Nunca más una clave en los logs.
- `**requestIdHeader**` reutiliza el id que llega del proxy o de otro servicio. Si no llega, `genReqId` genera un UUID.
- `**LogController**` es nuevo en Fastify 5.12: controla las líneas de log automáticas. Aquí lo uso para no registrar el health check, que en Coolify se llama cada pocos segundos. La antigua opción `disableRequestLogging` a secas funciona, pero lanza el aviso **FSTDEP023**.
- En desarrollo, `transport: { target: 'pino-pretty' }` muestra los logs con colores. En producción, deja el JSON: Coolify, Loki o cualquier agregador lo entienden.

## Manejo de errores

Fastify distingue tres situaciones: un error de validación (400 automático), un error que lanzas tú a propósito y un error inesperado (un bug). Para las dos últimas conviene tener **errores con código propio** y **un único manejador** que dé forma a todas las respuestas. [@fastify/error](https://github.com/fastify/fastify-error?ref=blog.devutilites.com) crea clases de error con código, mensaje con formato estilo `printf` y status HTTP:

```javascript
// errores.js: errores propios, manejador global y 404
import Fastify from 'fastify'
import createError from '@fastify/error'

// Errores con código, mensaje con formato y status HTTP
const NoEncontrado = createError('APP_NO_ENCONTRADO', 'No existe el recurso %s', 404)
const SinSaldo = createError('APP_SIN_SALDO', 'Saldo insuficiente: faltan %d €', 409)

const app = Fastify({ logger: false })

app.get('/pedidos/:id', async (request) => {
  throw new NoEncontrado(`pedido ${request.params.id}`)
})

app.post('/pagar', async () => {
  throw new SinSaldo(15)
})

app.get('/bug', async () => {
  const cliente = undefined
  return cliente.nombre // TypeError: un fallo de verdad
})

app.post('/validado', {
  schema: { body: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } }
}, async () => ({ ok: true }))

// Un único sitio para dar forma a TODAS las respuestas de error
app.setErrorHandler((error, request, reply) => {
  // 1. Errores de validación del schema
  if (error.validation) {
    return reply.code(400).send({
      error: 'datos_invalidos',
      detalles: error.validation.map(v => ({ campo: v.instancePath || v.params.missingProperty, problema: v.message }))
    })
  }
  // 2. Errores "esperados" (4xx): se enseñan tal cual
  const status = error.statusCode ?? 500
  if (status < 500) {
    return reply.code(status).send({ error: error.code, mensaje: error.message })
  }
  // 3. Errores inesperados: se registran con todo detalle, pero al cliente no se le cuenta nada
  request.log.error({ err: error }, 'error no controlado')
  return reply.code(500).send({ error: 'interno', mensaje: 'Algo ha ido mal. Ya lo estamos mirando.' })
})

// Rutas que no existen
app.setNotFoundHandler((request, reply) => {
  reply.code(404).send({ error: 'ruta_no_encontrada', ruta: `${request.method} ${request.url}` })
})

const pruebas = [
  ['GET', '/pedidos/77'], ['POST', '/pagar'], ['GET', '/bug'],
  ['POST', '/validado', { email: 'nope' }], ['POST', '/validado', {}], ['GET', '/no-existe']
]
for (const [method, url, payload] of pruebas) {
  const r = await app.inject({ method, url, payload })
  console.log(method, url, '→', r.statusCode, r.body)
}
```

```text
GET /pedidos/77   → 404 {"error":"APP_NO_ENCONTRADO","mensaje":"No existe el recurso pedido 77"}
POST /pagar       → 409 {"error":"APP_SIN_SALDO","mensaje":"Saldo insuficiente: faltan 15 €"}
GET /bug          → 500 {"error":"interno","mensaje":"Algo ha ido mal. Ya lo estamos mirando."}
POST /validado    → 400 {"error":"datos_invalidos","detalles":[{"campo":"/email","problema":"must match format \"email\""}]}
POST /validado    → 400 {"error":"datos_invalidos","detalles":[{"campo":"email","problema":"must have required property 'email'"}]}
GET /no-existe    → 404 {"error":"ruta_no_encontrada","ruta":"GET /no-existe"}
```

El patrón importante está en el manejador: los errores **4xx** se enseñan al cliente, porque son culpa suya y necesita saber qué corregir; los **5xx** se registran con todo detalle en el log, pero al cliente solo le llega un mensaje genérico. Por defecto, Fastify devuelve el mensaje del error también en los 500 (en el ejemplo de hooks, `/roto` respondía `"message":"ups"`), y eso puede filtrar detalles internos.

### Dos trampas que me hicieron perder un rato

Montando la API de ejemplo de este artículo, mis tests de errores fallaban con un 500 en lugar de un 404\. Había dos problemas encadenados, y los dos son de manual:

**Trampa 1: el schema de respuesta también se aplica a los errores.** Si declaras `response: { 404: ... }`, el cuerpo del 404 tiene que cumplir ese schema. El manejador de errores por defecto de Fastify devuelve `{statusCode, code, error, message}`; si tu schema exige otro campo, la serialización falla y acabas con un 500:

```javascript
// trampa404.js: el schema de respuesta también se aplica a los errores
import Fastify from 'fastify'

const app = Fastify({ logger: { level: 'error' } })

app.get('/x', {
  schema: {
    response: {
      404: {
        type: 'object',
        required: ['mensaje'],                 // mi formato de error...
        properties: { mensaje: { type: 'string' } }
      }
    }
  }
}, async () => {
  const error = new Error('no está')
  error.statusCode = 404
  throw error                                  // ...pero el manejador por defecto responde otro
})

const r = await app.inject('/x')
console.log(r.statusCode, r.body)
```

```text
500 {"statusCode":500,"code":"FST_ERR_FAILED_ERROR_SERIALIZATION",
     "message":"Failed to serialize an error. Error: \"mensaje\" is required!. Original error: no está"}
```

**Trampa 2: `setErrorHandler` está encapsulado y cuenta el orden.** Yo lo había puesto al final de `construirApp()`, después de registrar las rutas con `await app.register(...)`. Pero cada contexto hijo hereda el manejador que exista **en el momento de crearse**, así que mis rutas seguían usando el de por defecto, que devolvía un cuerpo incompatible con mi schema de 404 (trampa 1). Solución: declarar `setErrorHandler` **antes** de registrar los plugins de rutas. Lo verás así en el código de la API.

## Seguridad: CORS, cabeceras, límites y JWT

Cuatro plugins oficiales cubren lo básico de cualquier API pública. En este ejemplo verás también `decorate` y `decorateRequest` trabajando juntos para proteger rutas:

```javascript
// seguridad.js: CORS, cabeceras, límite de peticiones y JWT
import Fastify from 'fastify'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import jwt from '@fastify/jwt'

const app = Fastify({ logger: false })

await app.register(helmet)                        // cabeceras de seguridad
await app.register(cors, {
  origin: ['https://devutilites.com', /\.devutilites\.com$/],
  credentials: true
})
await app.register(rateLimit, {
  max: 100,                                       // 100 peticiones...
  timeWindow: '1 minute'                          // ...por minuto y por IP
})
await app.register(jwt, {
  secret: process.env.JWT_SECRET ?? 'cambia-esto-por-un-secreto-largo'
})

// decorateRequest: reserva la propiedad en el prototipo (más rápido que añadirla a mano)
app.decorateRequest('usuario', null)

// decorate: un "preHandler" reutilizable para las rutas protegidas
app.decorate('autenticar', async function (request, reply) {
  try {
    const datos = await request.jwtVerify()        // lee "Authorization: Bearer ..."
    request.usuario = { id: datos.sub, rol: datos.rol }
  } catch {
    return reply.code(401).send({ error: 'token_invalido' })
  }
})

app.post('/login', {
  config: { rateLimit: { max: 5, timeWindow: '1 minute' } }, // más estricto en el login
  schema: {
    body: {
      type: 'object',
      required: ['usuario', 'clave'],
      properties: { usuario: { type: 'string' }, clave: { type: 'string' } }
    }
  }
}, async (request, reply) => {
  const { usuario, clave } = request.body
  if (usuario !== 'ada' || clave !== 'lovelace') {   // aquí iría tu base de datos
    return reply.code(401).send({ error: 'credenciales_incorrectas' })
  }
  const token = await reply.jwtSign({ sub: '1', rol: 'admin' }, { expiresIn: '1h' })
  return { token }
})

app.get('/perfil', { onRequest: [app.autenticar] }, async (request) => {
  return { usuario: request.usuario }
})

await app.listen({ port: 3000, host: '0.0.0.0' })
```

```bash
$ curl -s localhost:3000/perfil
{"error":"token_invalido"}

$ TOKEN=$(curl -s -XPOST localhost:3000/login -H 'content-type: application/json' \
    -d '{"usuario":"ada","clave":"lovelace"}' | jq -r .token)
$ curl -s localhost:3000/perfil -H "authorization: Bearer $TOKEN"
{"usuario":{"id":"1","rol":"admin"}}

# Cabeceras que añaden helmet, cors y rate-limit:
Content-Security-Policy: default-src 'self';base-uri 'self';...
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: SAMEORIGIN
access-control-allow-origin: https://blog.devutilites.com
x-ratelimit-limit: 100
x-ratelimit-remaining: 98

# Siete intentos de login con la clave mal, seguidos:
401 401 401 401 429 429 429
{"statusCode":429,"error":"Too Many Requests","message":"Rate limit exceeded, retry in 1 minute"}
```

- **`@fastify/helmet`** añade una política de seguridad de contenido (CSP), HSTS, protección contra *clickjacking* y otras cabeceras. Si sirves una interfaz web desde el mismo servidor (como Swagger UI), tendrás que relajar la CSP.
- `**@fastify/cors**` acepta cadenas, expresiones regulares y funciones en `origin`. Nunca pongas `origin: '*'` con `credentials: true`.
- `**@fastify/rate-limit**` permite límites globales y por ruta (`config.rateLimit`). En el login lo dejé en 5 por minuto: la sexta petición ya recibe un 429\. Con varias instancias, usa su opción `redis` para compartir los contadores.
- `**@fastify/jwt**` añade `reply.jwtSign()` y `request.jwtVerify()`. El secreto, siempre desde una variable de entorno y largo.

El patrón `app.decorate('autenticar', ...)` \+ `onRequest: [app.autenticar]` es el más habitual para proteger rutas sueltas. Si quieres proteger un grupo entero, mete esas rutas en un plugin y añade dentro `instancia.addHook('onRequest', instancia.autenticar)`: la encapsulación hará que solo afecte a ese grupo.

🚨

****Detrás de un proxy, activa** **`trustProxy`** **.** En Coolify, Traefik está delante de tu aplicación; con Cloudflare, además, hay otro salto. Sin `trustProxy`, `request.ip` es la IP del proxy y el límite de peticiones castigaría a todos tus usuarios a la vez como si fueran uno.

```javascript
// Detrás de Traefik/Coolify o Cloudflare, la IP real viene en X-Forwarded-For
const app = Fastify({
  logger: true,
  trustProxy: true   // o la lista de IPs de tus proxies, más seguro
})
// Sin esto, request.ip es la del proxy y @fastify/rate-limit
// trataría a todos tus usuarios como si fueran uno solo
```

## Proyecto completo: una API de tareas en TypeScript

Ahora juntamos todo en una API real con **CRUD completo**, estructurada como lo haría en un proyecto de verdad: la construcción de la app separada del arranque (para poder testearla), un plugin compartido para los datos, un plugin de rutas encapsulado, TypeScript con los tipos **inferidos de los schemas**, documentación OpenAPI automática, tests y apagado ordenado.

```text
api-tareas/
├── package.json
├── tsconfig.json
├── Dockerfile
├── src/
│   ├── schemas.ts          # schemas TypeBox = validación + tipos + docs
│   ├── plugins/almacen.ts  # datos compartidos (fastify-plugin)
│   ├── routes/tareas.ts    # CRUD encapsulado bajo /tareas
│   ├── app.ts              # construirApp(): registra todo, no escucha
│   └── server.ts           # arranca y gestiona el apagado
└── test/
    └── tareas.test.ts      # node:test + inject()
```

```json
{
  "name": "api-tareas",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "@fastify/cors": "^11.3.0",
    "@fastify/error": "^4.2.0",
    "@fastify/helmet": "^13.1.1",
    "@fastify/swagger": "^9.9.0",
    "@fastify/swagger-ui": "^6.1.1",
    "@fastify/type-provider-typebox": "^6.1.0",
    "close-with-grace": "^2.5.0",
    "fastify": "^5.12.5",
    "fastify-plugin": "^6.0.0",
    "typebox": "^1.3.34"
  },
  "devDependencies": {
    "@types/node": "^26.6.3",
    "pino-pretty": "^13.1.3",
    "tsx": "^4.23.15",
    "typescript": "^7.0.2"
  }
}
```

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"]
}
```

### Los schemas con TypeBox: un solo sitio para todo

El problema de usar JSON Schema con TypeScript es que acabas escribiendo cada cosa dos veces: el schema y la interfaz. Los **type providers** lo resuelven: [TypeBox](https://github.com/sinclairzx81/typebox?ref=blog.devutilites.com) construye el JSON Schema con funciones (`Type.Object`, `Type.String`...) y TypeScript deduce el tipo. Fastify, a través de `@fastify/type-provider-typebox`, usa ese tipo para `request.body`, `request.params`, etc. Si cambias el schema, cambian los tipos. Alternativas equivalentes: `json-schema-to-ts` o `fastify-type-provider-zod` si ya usas Zod.

```typescript
// src/schemas.ts: los schemas son a la vez validación, tipos y documentación
import { Type, type Static } from '@fastify/type-provider-typebox'

export const Tarea = Type.Object({
  id: Type.Integer(),
  titulo: Type.String(),
  hecha: Type.Boolean(),
  creadaEn: Type.String({ format: 'date-time' })
}, { $id: 'Tarea' })

export const NuevaTarea = Type.Object({
  titulo: Type.String({ minLength: 1, maxLength: 200 }),
  hecha: Type.Optional(Type.Boolean({ default: false }))
}, { additionalProperties: false })

export const CambiosTarea = Type.Partial(NuevaTarea, { additionalProperties: false, minProperties: 1 })

export const ParamsId = Type.Object({ id: Type.Integer({ minimum: 1 }) })

export const FiltroTareas = Type.Object({
  hecha: Type.Optional(Type.Boolean()),
  limite: Type.Integer({ minimum: 1, maximum: 100, default: 20 })
})

export const ErrorRespuesta = Type.Object({ error: Type.String(), mensaje: Type.String() })

export type TareaT = Static<typeof Tarea>
export type NuevaTareaT = Static<typeof NuevaTarea>
```

⚠️

El type provider de TypeBox 6.x usa el paquete **`typebox`** (versión 1.x), no el antiguo `@sinclair/typebox`. Muchos tutoriales siguen con el viejo; si instalas los dos, los tipos no encajan. Instala `npm i typebox @fastify/type-provider-typebox`.

### El plugin de datos

Usa `fastify-plugin` porque queremos que `app.almacen` esté disponible en toda la aplicación. El `declare module` le cuenta a TypeScript que la instancia tiene esa propiedad. Para el ejemplo guarda los datos en memoria; más abajo verás la versión con PostgreSQL.

```typescript
// src/plugins/almacen.ts: el "repositorio" de tareas, compartido por toda la app
import fp from 'fastify-plugin'
import type { TareaT, NuevaTareaT } from '../schemas.js'

export interface Almacen {
  listar (filtro: { hecha?: boolean, limite: number }): TareaT[]
  obtener (id: number): TareaT | undefined
  crear (datos: NuevaTareaT): TareaT
  actualizar (id: number, cambios: Partial<NuevaTareaT>): TareaT | undefined
  borrar (id: number): boolean
}

// Así TypeScript sabe que app.almacen existe en todas partes
declare module 'fastify' {
  interface FastifyInstance { almacen: Almacen }
}

export default fp(async (app) => {
  // En memoria para el ejemplo; en producción, @fastify/postgres (más abajo)
  const tareas = new Map<number, TareaT>()
  let siguienteId = 1

  const almacen: Almacen = {
    listar ({ hecha, limite }) {
      return [...tareas.values()]
        .filter(t => hecha === undefined || t.hecha === hecha)
        .slice(0, limite)
    },
    obtener: (id) => tareas.get(id),
    crear ({ titulo, hecha = false }) {
      const tarea = { id: siguienteId++, titulo, hecha, creadaEn: new Date().toISOString() }
      tareas.set(tarea.id, tarea)
      return tarea
    },
    actualizar (id, cambios) {
      const tarea = tareas.get(id)
      if (!tarea) return undefined
      Object.assign(tarea, cambios)
      return tarea
    },
    borrar: (id) => tareas.delete(id)
  }

  app.decorate('almacen', almacen)
  app.addHook('onClose', async () => { tareas.clear() })
}, { name: 'almacen' })
```

### Las rutas: el CRUD

Aquí se ve la gracia del type provider: `request.query`, `request.params.id` y `request.body` están tipados sin escribir ni una interfaz. Si intentas leer `request.body.titulo` en una ruta sin body, TypeScript protesta.

```typescript
// src/routes/tareas.ts: CRUD completo con tipos inferidos de los schemas
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { Type } from '@fastify/type-provider-typebox'
import createError from '@fastify/error'
import { Tarea, NuevaTarea, CambiosTarea, ParamsId, FiltroTareas, ErrorRespuesta } from '../schemas.js'

const TareaNoEncontrada = createError('TAREA_NO_ENCONTRADA', 'No existe la tarea %s', 404)

const rutas: FastifyPluginAsyncTypebox = async (app) => {
  // GET /tareas?hecha=true&limite=10
  app.get('/', {
    schema: {
      tags: ['tareas'],
      summary: 'Lista las tareas',
      querystring: FiltroTareas,
      response: { 200: Type.Array(Type.Ref('Tarea')) }
    }
  }, async (request) => {
    // request.query es { hecha?: boolean, limite: number }: sin escribir ningún tipo
    return app.almacen.listar(request.query)
  })

  // GET /tareas/:id
  app.get('/:id', {
    schema: {
      tags: ['tareas'],
      params: ParamsId,
      response: { 200: Type.Ref('Tarea'), 404: ErrorRespuesta }
    }
  }, async (request) => {
    const tarea = app.almacen.obtener(request.params.id) // id: number
    if (!tarea) throw new TareaNoEncontrada(request.params.id)
    return tarea
  })

  // POST /tareas
  app.post('/', {
    schema: {
      tags: ['tareas'],
      body: NuevaTarea,
      response: { 201: Type.Ref('Tarea') }
    }
  }, async (request, reply) => {
    const tarea = app.almacen.crear(request.body)
    return reply.code(201).header('location', `/tareas/${tarea.id}`).send(tarea)
  })

  // PATCH /tareas/:id
  app.patch('/:id', {
    schema: {
      tags: ['tareas'],
      params: ParamsId,
      body: CambiosTarea,
      response: { 200: Type.Ref('Tarea'), 404: ErrorRespuesta }
    }
  }, async (request) => {
    const tarea = app.almacen.actualizar(request.params.id, request.body)
    if (!tarea) throw new TareaNoEncontrada(request.params.id)
    return tarea
  })

  // DELETE /tareas/:id
  app.delete('/:id', {
    schema: {
      tags: ['tareas'],
      params: ParamsId,
      response: { 204: Type.Null(), 404: ErrorRespuesta }
    }
  }, async (request, reply) => {
    if (!app.almacen.borrar(request.params.id)) throw new TareaNoEncontrada(request.params.id)
    return reply.code(204).send(null)
  })
}

export default rutas
```

### La aplicación y el arranque

`construirApp()` crea la instancia pero **no la pone a escuchar**. Así los tests pueden construir una aplicación nueva para cada prueba sin abrir puertos. Fíjate en el orden: el manejador de errores va antes que las rutas (trampa 2), y `refResolver` hace que el documento OpenAPI use el nombre `Tarea` en vez de `def-0`.

```typescript
// src/app.ts: construye la aplicación, sin arrancarla (así se puede testear)
import Fastify, { type FastifyError, type FastifyServerOptions } from 'fastify'
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import helmet from '@fastify/helmet'
import cors from '@fastify/cors'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import almacen from './plugins/almacen.js'
import rutasTareas from './routes/tareas.js'
import { Tarea } from './schemas.js'

export async function construirApp (opts: FastifyServerOptions = {}) {
  const app = Fastify(opts).withTypeProvider<TypeBoxTypeProvider>()

  app.addSchema(Tarea) // disponible como Type.Ref('Tarea') en todas las rutas

  await app.register(helmet, { contentSecurityPolicy: false }) // la UI de Swagger necesita scripts en línea
  await app.register(cors, { origin: true })

  await app.register(swagger, {
    openapi: {
      info: { title: 'API de tareas', version: '1.0.0' },
      tags: [{ name: 'tareas', description: 'Gestión de tareas' }]
    },
    // Usa el $id de cada schema ("Tarea") como nombre en el documento, en vez de def-0
    refResolver: { buildLocalReference: (json, _base, _frag, i) => String(json.$id ?? `def-${i}`) }
  })
  await app.register(swaggerUi, { routePrefix: '/docs' })

  // OJO: antes de registrar las rutas. Los contextos hijos heredan el manejador
  // que exista en el momento de crearse; si lo pones al final, no les llega.
  app.setErrorHandler<FastifyError>((error, request, reply) => {
    if (error.validation) {
      return reply.code(400).send({ error: 'datos_invalidos', mensaje: error.message })
    }
    const status = error.statusCode ?? 500
    if (status >= 500) request.log.error({ err: error }, 'error no controlado')
    return reply.code(status).send({
      error: error.code ?? 'interno',
      mensaje: status >= 500 ? 'Error interno' : error.message
    })
  })

  await app.register(almacen)
  await app.register(rutasTareas, { prefix: '/tareas' })

  app.get('/salud', { schema: { hide: true } }, async () => ({ ok: true }))

  return app
}
```

```typescript
// src/server.ts: arranque y apagado ordenado
import closeWithGrace from 'close-with-grace'
import { construirApp } from './app.js'

const app = await construirApp({
  logger: process.env.NODE_ENV === 'production'
    ? { level: 'info' }                                   // JSON puro en producción
    : { level: 'debug', transport: { target: 'pino-pretty' } }
})

// Ante SIGINT/SIGTERM (p. ej. un despliegue en Coolify): deja de aceptar
// conexiones, termina las peticiones en curso y ejecuta los hooks onClose
closeWithGrace({ delay: 10_000 }, async ({ signal, err }) => {
  if (err) app.log.error({ err }, 'cerrando por un error')
  else app.log.info({ signal }, 'cerrando')
  await app.close()
})

await app.listen({
  port: Number(process.env.PORT ?? 3000),
  host: '0.0.0.0' // imprescindible dentro de un contenedor
})
```

[close-with-grace](https://github.com/mcollina/close-with-grace?ref=blog.devutilites.com) escucha `SIGINT`, `SIGTERM` y los errores no capturados, y llama a `app.close()`. Eso deja de aceptar conexiones, espera a que terminen las peticiones en curso y ejecuta todos los hooks `onClose` (en nuestro caso, vaciar el almacén; en uno real, cerrar la base de datos). Con `delay` le das un máximo de 10 segundos antes de salir a la fuerza. Es lo que necesita Coolify para que un despliegue nuevo no corte peticiones a medias.

```bash
$ npm run build && PORT=3000 NODE_ENV=production npm start

$ curl -s -XPOST localhost:3000/tareas -H 'content-type: application/json' -d '{"titulo":"probar en real"}'
{"id":1,"titulo":"probar en real","hecha":false,"creadaEn":"2026-09-29T17:45:08.805Z"}

$ curl -s "localhost:3000/tareas?limite=5"
[{"id":1,"titulo":"probar en real","hecha":false,"creadaEn":"2026-09-29T17:45:08.805Z"}]

# La documentación interactiva, en el navegador: http://localhost:3000/docs
# El documento OpenAPI en JSON: http://localhost:3000/docs/json

$ kill -TERM <pid>
{"level":30,"signal":"SIGTERM","msg":"cerrando"}      # close-with-grace cierra con orden
```

En `/docs` tienes Swagger UI con todas las rutas, sus parámetros, sus schemas de entrada y salida y un botón para probarlas. No has escrito ni una línea de documentación: sale de los mismos schemas que validan y serializan.

### Tests con node:test e inject()

`app.inject()` simula una petición HTTP completa (pasa por el router, los hooks, la validación y la serialización) **sin abrir ningún socket**. Los tests son rapidísimos y no chocan por puertos ocupados. Uso el test runner que trae Node.js (`node:test`), sin instalar Jest ni Vitest:

```typescript
// test/tareas.test.ts: pruebas sin abrir ningún puerto, con inject()
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { construirApp } from '../src/app.js'

test('CRUD de tareas', async (t) => {
  const app = await construirApp()
  t.after(() => app.close()) // cierra la app al terminar (y ejecuta onClose)

  await t.test('crea una tarea', async () => {
    const res = await app.inject({ method: 'POST', url: '/tareas', payload: { titulo: 'Escribir el artículo' } })
    assert.equal(res.statusCode, 201)
    assert.equal(res.headers.location, '/tareas/1')
    const tarea = res.json()
    assert.equal(tarea.titulo, 'Escribir el artículo')
    assert.equal(tarea.hecha, false)          // valor por defecto del schema
  })

  await t.test('rechaza un body inválido', async () => {
    const res = await app.inject({ method: 'POST', url: '/tareas', payload: { titulo: '' } })
    assert.equal(res.statusCode, 400)
    assert.equal(res.json().error, 'datos_invalidos')
  })

  await t.test('marca la tarea como hecha', async () => {
    const res = await app.inject({ method: 'PATCH', url: '/tareas/1', payload: { hecha: true } })
    assert.equal(res.statusCode, 200)
    assert.equal(res.json().hecha, true)
  })

  await t.test('filtra por query string', async () => {
    await app.inject({ method: 'POST', url: '/tareas', payload: { titulo: 'Publicarlo' } })
    const res = await app.inject({ url: '/tareas?hecha=false' })
    assert.deepEqual(res.json().map((t: { titulo: string }) => t.titulo), ['Publicarlo'])
  })

  await t.test('404 con código de error propio', async () => {
    const res = await app.inject({ url: '/tareas/999' })
    assert.equal(res.statusCode, 404)
    assert.deepEqual(res.json(), { error: 'TAREA_NO_ENCONTRADA', mensaje: 'No existe la tarea 999' })
  })

  await t.test('borra', async () => {
    const res = await app.inject({ method: 'DELETE', url: '/tareas/1' })
    assert.equal(res.statusCode, 204)
    assert.equal((await app.inject({ url: '/tareas/1' })).statusCode, 404)
  })

  await t.test('genera el documento OpenAPI', async () => {
    await app.ready()
    const doc = app.swagger() as { paths: Record<string, unknown> }
    assert.ok(doc.paths['/tareas/{id}'])
  })
})
```

```text
$ npm test
# Subtest: CRUD de tareas
    ok 1 - crea una tarea
    ok 2 - rechaza un body inválido
    ok 3 - marca la tarea como hecha
    ok 4 - filtra por query string
    ok 5 - 404 con código de error propio
    ok 6 - borra
    ok 7 - genera el documento OpenAPI
ok 1 - CRUD de tareas
# tests 8
# pass 8
# fail 0
```

## Con base de datos: @fastify/postgres

Cambiar el almacén en memoria por PostgreSQL es cuestión de un plugin. [@fastify/postgres](https://github.com/fastify/fastify-postgres?ref=blog.devutilites.com) crea un pool de conexiones de `pg`, lo expone como `app.pg`, lo cierra solo al apagar y trae un ayudante para transacciones. Este ejemplo lo probé contra un PostgreSQL 17 en Docker:

```javascript
// postgres.js: el mismo CRUD, ahora con PostgreSQL de verdad
import Fastify from 'fastify'
import postgres from '@fastify/postgres'

const app = Fastify({ logger: false })

await app.register(postgres, {
  connectionString: process.env.DATABASE_URL // postgres://usuario:clave@host:5432/bd
})

// Crea la tabla al arrancar (en un proyecto real, usa migraciones)
app.addHook('onReady', async () => {
  await app.pg.query(`CREATE TABLE IF NOT EXISTS tareas (
    id SERIAL PRIMARY KEY,
    titulo TEXT NOT NULL,
    hecha BOOLEAN NOT NULL DEFAULT false,
    creada_en TIMESTAMPTZ NOT NULL DEFAULT now()
  )`)
})

app.get('/tareas', async () => {
  const { rows } = await app.pg.query('SELECT * FROM tareas ORDER BY id')
  return rows
})

app.post('/tareas', {
  schema: {
    body: {
      type: 'object',
      required: ['titulo'],
      properties: { titulo: { type: 'string', minLength: 1 } }
    }
  }
}, async (request, reply) => {
  // Siempre con parámetros ($1): nunca concatenes texto del usuario en el SQL
  const { rows } = await app.pg.query(
    'INSERT INTO tareas (titulo) VALUES ($1) RETURNING *',
    [request.body.titulo]
  )
  return reply.code(201).send(rows[0])
})

// Transacción: si algo falla dentro, se hace ROLLBACK automáticamente
app.post('/tareas/completar-todas', async () => {
  return app.pg.transact(async (client) => {
    const { rowCount } = await client.query('UPDATE tareas SET hecha = true WHERE NOT hecha')
    return { actualizadas: rowCount }
  })
})

await app.listen({ port: 3000, host: '0.0.0.0' })
```

```bash
$ curl -s -XPOST localhost:3000/tareas -H 'content-type: application/json' \
    -d "{\"titulo\":\"Robert'); DROP TABLE tareas;--\"}"
{"id":1,"titulo":"Robert'); DROP TABLE tareas;--","hecha":false,"creada_en":"2026-09-29T17:46:20.934Z"}
# La tabla sigue ahí: el texto se guardó como texto, gracias a $1

$ curl -s -XPOST localhost:3000/tareas/completar-todas
{"actualizadas":2}
```

Si prefieres un ORM o un *query builder*, Fastify no te ata a nada: Prisma, Drizzle o Kysely se integran con un plugin de 10 líneas que decore la instancia con el cliente y lo cierre en `onClose`.

| Plugin                                                                                      | Para qué                                                      | Versión probada        |
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------- |
| @fastify/cors                                                                               | Cabeceras CORS                                                | 11.3.0                 |
| @fastify/helmet                                                                             | Cabeceras de seguridad (CSP, HSTS...)                         | 13.1.1                 |
| @fastify/rate-limit                                                                         | Límite de peticiones por IP o por clave                       | 11.2.0                 |
| @fastify/jwt                                                                                | Firmar y verificar tokens JWT                                 | 10.2.2                 |
| @fastify/swagger \+ @fastify/swagger-ui                                                     | OpenAPI generado desde tus schemas + interfaz web             | 9.9.0 / 6.1.1          |
| @fastify/postgres                                                                           | Pool de pg compartido, con transacciones                      | 6.1.0                  |
| @fastify/error                                                                              | Crear errores con código y status                             | 4.2.0                  |
| @fastify/type-provider-typebox                                                              | Tipos de TypeScript desde los schemas                         | 6.1.0                  |
| @fastify/autoload                                                                           | Cargar plugins y rutas desde carpetas                         | (lo trae el generador) |
| @fastify/static, @fastify/multipart, @fastify/cookie, @fastify/websocket, @fastify/compress | Ficheros estáticos, subidas, cookies, websockets, gzip/brotli | —                      |

## Errores comunes

### Mezclar async con reply.send() en un callback

Es el error número uno de quien viene de Express. En un handler `async`, Fastify toma lo que devuelves como respuesta. Si respondes más tarde desde un callback y no devuelves nada, Fastify envía una respuesta vacía **inmediatamente**, y cuando tu callback llama a `send()` ya es tarde:

```javascript
// trampas.js: responder tarde desde un handler async
import Fastify from 'fastify'
const app = Fastify({ logger: { level: 'warn' } })

// MAL: async + respuesta en un callback, sin devolver nada
app.get('/mal', async (request, reply) => {
  setTimeout(() => reply.send({ tarde: true }), 10)
})

// BIEN: devolver reply indica "ya contestaré yo"
app.get('/bien', async (request, reply) => {
  setTimeout(() => reply.send({ tarde: true }), 10)
  return reply
})

// MAL: send y además return
app.get('/doble', async (request, reply) => {
  reply.send({ a: 1 })
  return { b: 2 }
})

for (const u of ['/mal', '/bien', '/doble']) {
  const r = await app.inject(u); console.log(u, r.statusCode, r.body)
}
```

```text
/mal   200 ""                  ← respuesta vacía, y en el log:
        "Reply was already sent, did you forget to "return reply" in "/mal" (GET)?"
/bien  200 {"tarde":true}
/doble 200 {"a":1}             ← y el mismo aviso en el log
```

Regla: en funciones `async`, **o devuelves el valor, o devuelves `reply`**. Mejor aún, convierte el callback en promesa y usa `await`.

### Olvidar fastify-plugin

Registras tu plugin de base de datos, decoras con `db`, y en las rutas `app.db` es `undefined`. Es la encapsulación: sin `fastify-plugin`, el decorador se quedó en el contexto hijo. Envuélvelo con `fp()`.

### Registrar sin esperar y usar demasiado pronto

`app.register()` no carga el plugin en ese instante: lo encola en avvio. Si justo después usas algo que ese plugin decora, puede no existir todavía. Usa `await app.register(...)` cuando necesites el resultado en la línea siguiente, o haz el trabajo dentro de otro plugin (que se cargará después) o en `onReady`.

### Escuchar en localhost dentro de Docker

El clásico "*Bad Gateway*" en Coolify. Si no pasas `host: '0.0.0.0'`, Fastify escucha en `127.0.0.1`, que dentro del contenedor es solo el propio contenedor, y Traefik no puede llegar. Lo explico con más detalle en [Coolify a fondo](https://blog.devutilites.com/coolify-paas-autoalojado/).

### Mutar el objeto que devuelve el schema de respuesta y esperar verlo

Añades un campo nuevo a la respuesta, lo devuelves… y no sale. No es un bug: no está en el schema de `response`, así que fast-json-stringify lo ignora. Añádelo al schema.

### Usar decorateRequest con un objeto

`app.decorateRequest('datos', {})` lanza un error en Fastify 5: ese objeto se compartiría entre todas las peticiones (y entre usuarios). Usa `null` y asígnalo en un hook, o pasa un getter.

## Despliegue: Docker y Coolify

Un Dockerfile multi-etapa: la primera compila TypeScript con todas las dependencias; la segunda solo lleva las de producción y el JavaScript compilado. Lo construí y lo arranqué en mi servidor: la imagen final pesa unos 195 MB y el health check pasa a *healthy* en segundos.

```dockerfile
# --- Etapa 1: compilar TypeScript ---
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# --- Etapa 2: imagen final, solo dependencias de producción ---
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/salud || exit 1
CMD ["node", "dist/server.js"]
```

```text
node_modules
dist
*.log
ej
test
```

```bash
docker build -t api-tareas .
docker run -d --name api-tareas -p 127.0.0.1:3000:3000 api-tareas
docker ps --format '{{.Names}}  {{.Status}}'
# api-tareas  Up 15 seconds (healthy)
```

En Coolify, crea una aplicación desde tu repositorio con el *build pack* **Dockerfile**, puerto **3000** y health check en `/salud`. Si quieres la API con su PostgreSQL en un solo recurso, usa Docker Compose con las variables mágicas de Coolify:

```yaml
services:
  api:
    build: .
    environment:
      - SERVICE_FQDN_API_3000            # Coolify crea el dominio y lo enruta al 3000
      - NODE_ENV=production
      - DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@db:5432/tareas
      - JWT_SECRET=${SERVICE_PASSWORD_JWT}
    depends_on:
      - db
  db:
    image: postgres:17-alpine
    environment:
      - POSTGRES_USER=${SERVICE_USER_POSTGRES}
      - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
      - POSTGRES_DB=tareas
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:
```

Checklist de producción:

- `host: '0.0.0.0'` y el puerto desde `process.env.PORT`.
- `NODE_ENV=production`, logs en JSON sin pino-pretty.
- `trustProxy` activado si hay Traefik o Cloudflare delante.
- Apagado ordenado con close-with-grace (Coolify manda `SIGTERM` al desplegar).
- Un health check barato (`/salud`) y excluido de los logs.
- Schemas de respuesta en todas las rutas públicas.
- Manejador de errores que no filtre detalles en los 5xx.
- Dependencias al día: Fastify saca parches de seguridad a menudo.

## Ventajas e inconvenientes

### Lo mejor

- **Validación y serialización integradas y basadas en un estándar.** Un schema te da validación, tipos, documentación y protección contra fugas de datos.
- **La encapsulación**: el sistema de plugins escala muy bien en proyectos grandes.
- **Logging y testing de primera**, sin instalar nada.
- **Rendimiento** muy alto con muy poco esfuerzo.
- **Ecosistema oficial cuidado**, mantenido por el mismo equipo y con versiones alineadas.
- **TypeScript** bien resuelto con los type providers.

### Lo peor

- **Curva de aprendizaje** mayor que Express: encapsulación, orden de registro, hooks async frente a callback… Al principio desconcierta.
- **Los middlewares de Express no funcionan directamente**; existe `@fastify/express`/`@fastify/middie` para compatibilidad, pero lo ideal es usar el plugin nativo.
- **Solo Node.js** (y compatibles como Bun, con matices). Si quieres desplegar en Cloudflare Workers o Deno Deploy, Hono encaja mejor.
- **Mucho JSON Schema**: verboso si lo escribes a mano (TypeBox lo alivia mucho).
- **Actualizaciones frecuentes**, muchas de seguridad: hay que estar atento.

### Cuándo lo elijo

Para una **API REST o JSON en Node.js** que vaya a crecer, Fastify es hoy mi opción por defecto. Si necesito una estructura muy opinada con inyección de dependencias para un equipo grande, miro NestJS (que puede usar Fastify por debajo). Si el destino es el *edge* (Workers, Deno, Bun), Hono. Si es un prototipo de una tarde o mantengo algo que ya existe, Express sigue siendo perfectamente válido.

## Sigue leyendo

Este artículo es parte de una serie sobre frameworks de backend en JavaScript, todos con el mismo nivel de detalle y código probado:

- [Hono a fondo](https://blog.devutilites.com/hono-a-fondo/): el framework ultraligero que funciona en cualquier runtime.
- [Express a fondo](https://blog.devutilites.com/express-a-fondo/): el clásico, ahora en su versión 5.
- [NestJS 12 a fondo](https://blog.devutilites.com/nestjs-12-a-fondo/): arquitectura por módulos, inyección de dependencias y todo lo nuevo de la versión 12.
- [Hono vs Fastify vs Express vs NestJS](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/): la comparativa, con pruebas de rendimiento reales y cuándo elegir cada uno.

Y si vas a desplegarlo en tu propio servidor, [Coolify a fondo](https://blog.devutilites.com/coolify-paas-autoalojado/) cuenta cómo lo hago yo.