Cargando
Miniatura del artículo

Arquitecto de automatización con IA: Día 34

Índice de contenido

Resumen:
Trigo Lento quiere un agente de pedidos y la mensajería ya se come más presupuesto del previsto, así que cada dólar de la plataforma cuenta. En esta entrada, Valeria compara n8n Cloud con una instalación propia de n8n Community: lee la licencia de uso sostenible, cuenta ejecuciones, calcula el coste total a 12 meses con sus horas incluidas, instala n8n con Docker Compose, Postgres y HTTPS, mide qué significa cada nivel de disponibilidad en mensajes de clientes y decide con una matriz ponderada que resiste la objeción de Don Ernesto. Al terminar, usted sabrá elegir dónde vive su n8n con números, levantarlo de forma segura y escribir la lista de responsabilidades que esa elección trae consigo.
¿Cómo citar el presente artículo?
Romero, J. (6 de marzo de 2026). Arquitecto de automatización con IA: Día 34. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_34/.
Arquitecto de automatización con IA: Día 34 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_34/.

Planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude revisados en octubre de 2026. Los precios de n8n Cloud (Starter a 20 EUR y Pro a 50 EUR al mes en pago anual, con un ahorro del 17 % frente al pago mensual), los cupos de 2 500 y 10 000 ejecuciones, el texto de la Sustainable Use License y su página de preguntas frecuentes, las variables de entorno de Postgres (DB_POSTGRESDB_*), la variable N8N_WEBHOOK_URL (disponible desde n8n 2.30.0; antes, WEBHOOK_URL) y la sintaxis de n8n export:workflow se contrastaron con las páginas oficiales de n8n en octubre de 2026. El servicio de Docker no estaba disponible al escribir la entrada, de modo que el workflow del Bloque 10 tiene la estructura verificada contra la documentación, revisado en octubre de 2026: no es la exportación de una instancia real y debe contrastarse con una exportación propia de la versión 2.43.7.

Día 34. n8n: nube frente a instalación propia

Introducción


Ayer, en el Día 33 (Sesión práctica: el escenario de pedidos de Trigo Lento, de punta a punta), Valeria integró en un solo escenario de Make el webhook firmado, la cascada de modelos, el router, la transformación, el almacén y el manejo de errores, lo probó con casos antes de activarlo y simuló un mes de operación, y Trigo Lento ganó TL Pedidos WhatsApp v3 en producción. Hoy comienza el módulo 4/8, Automatización avanzada de agentes con n8n: doce días para llevar a la panadería a lo que Make no resuelve bien con su presupuesto, que son los agentes con herramientas y supervisión humana, las integraciones sin conector y los procesos masivos, en una instancia de n8n propia.

Es viernes 6 de marzo, a las siete de la noche. La última hornada de baguettes de la Cafetería Brújula ya salió y Valeria tiene en la pantalla el resumen del mes simulado de ayer. El escenario v3 funciona, pero el rubro que rompió el presupuesto no fue la IA: Claude cuesta unos $1.47$ USD al mes con la cascada, mientras que la mensajería con Twilio suma $21.92$ USD frente a un rubro de $12$. El módulo 5 atacará ese problema; hoy toca otro que depende de él. Lo siguiente en el mapa del Día 21 es un agente que atienda los pedidos con herramientas y pida aprobación a una persona antes de comprometer un pastel, y ese agente vivirá en n8n. La pregunta es dónde vive n8n.

Hay dos caminos. El primero es n8n Cloud: se paga una suscripción, n8n mantiene el servidor y Valeria solo dibuja workflows. El segundo es instalar n8n Community en un servidor pequeño alquilado: el software es gratuito, el servidor cuesta unos pocos dólares, y todo lo demás (actualizaciones, copias de seguridad, certificados, el disco que se llena) pasa a ser trabajo de Valeria. Don Ernesto, que escucha mientras anota las compras del lunes, va directo al punto: “Usted me dijo en febrero que con un servidor propio nos ahorrábamos la cuota. Muy bien. Pero yo tengo un horno que no pago al mes, y cuando se descompone un domingo, el que se queda sin pan soy yo. ¿Su servidor no es lo mismo?” La comparación es exacta, y por eso merece una respuesta con números y no con entusiasmo.

En la presente entrada, descubrirá qué es n8n, qué permite y qué prohíbe su licencia de uso sostenible y por qué su unidad de cobro, la ejecución, le conviene a un negocio con muchos pasos por mensaje. Comparará n8n Cloud Starter y Pro con la instalación propia en coste, mantenimiento, ubicación de los datos, actualizaciones, escalado y soporte, y calculará el coste total a 12 meses con las horas de Valeria dentro de la cuenta. Después instalará n8n con Docker Compose, Postgres y un proxy con HTTPS, con la zona horaria de la Ciudad de México y una clave de cifrado que no vive en el repositorio. Medirá qué significa un $99.5%$ de disponibilidad en minutos y en mensajes de clientes, simulará mil meses de caídas para responder a la pregunta del domingo, decidirá con una matriz ponderada que pone a prueba los pesos de Don Ernesto y terminará con la lista de responsabilidades, la política de copias y el primer workflow exportado de la instancia.

Objetivos de aprendizaje


Al terminar esta entrada, usted podrá:

  • Explicar qué es n8n, su licencia (Sustainable Use License) y su unidad de cobro (ejecuciones).
  • Comparar n8n Cloud con la instalación propia por coste total, mantenimiento, datos y disponibilidad.
  • Instalar n8n con Docker Compose y Postgres, con HTTPS, zona horaria y clave de cifrado.
  • Calcular la disponibilidad de un servidor pequeño y su efecto en los mensajes de la hora pico.

Qué es n8n


n8n es una plataforma de automatización de flujos de trabajo de tipo low-code. En el espectro que Valeria dibujó el Día 11 (No-code, low-code y código: dónde está cada herramienta) ocupaba la posición $60$ de $100$: más cerca del código que Make, porque cada paso se configura con formularios, pero cualquier paso puede ser un nodo de código en JavaScript o Python, y la plataforma entera se puede instalar en un servidor propio. Su vocabulario tiene cuatro palabras que la serie usará todo el módulo:

  • Workflow (flujo de trabajo): el equivalente del escenario de Make. Es un grafo de nodos guardado como un documento JSON con dos partes, nodes y connections, que se exporta y se importa sin pérdida.
  • Nodo: cada paso del workflow. Hay nodos disparadores (un webhook, una programación horaria, un formulario), nodos de acción (una petición HTTP, una fila en Postgres), nodos de decisión (IF, Switch) y, en el paquete de IA, nodos de agente, de modelo, de memoria y de herramientas, que el Día 37 estudiará a fondo.
  • Item: cada registro que viaja entre nodos. Un nodo recibe una lista de items y devuelve otra; si un nodo produce cincuenta items, el siguiente se ejecuta para los cincuenta sin necesidad de un iterador explícito.
  • Ejecución: una corrida completa del workflow, desde que se dispara hasta que termina, tenga los nodos que tenga y procese los items que procese.

La última definición es la que más importa para el presupuesto, y la siguiente sección la retoma. Antes, la licencia.

La licencia de uso sostenible

n8n no se publica con una licencia de código abierto en el sentido de la Open Source Initiative, sino con la Sustainable Use License, una licencia propia que su autor describe como de código justo (en inglés, fair-code): el código fuente es público, se puede leer, modificar y desplegar, pero con una restricción comercial. La regla práctica que dan la licencia y su página de preguntas frecuentes, consultadas en octubre de 2026, es esta: se permite todo uso salvo vender un producto o servicio cuyo valor provenga entera o sustancialmente de n8n. La pregunta que la propia documentación sugiere hacerse es si n8n es lo que usted vende o una herramienta que sostiene lo que usted vende.

Para Trigo Lento la respuesta es clara, pero conviene escribirla, porque el código de honor del Día 6 pide leer las licencias antes de construir sobre ellas:

  1. Permitido: usar n8n para los procesos internos de la panadería (pedidos, recordatorios, facturación), aunque los clientes reciban lo que los workflows producen; la documentación lo llama propósito interno de negocio.
  2. Permitido: que Valeria, algún día, cobre a otro negocio por montar y mantener workflows en una instancia de ese negocio, o por formación y asesoría.
  3. No permitido: ofrecer a terceros una instancia de n8n alojada por Trigo Lento en la que ellos creen sus propios workflows, o revender n8n con otra marca.

Hay un matiz más. Algunas funciones del repositorio (entornos con control de versiones, inicio de sesión único, registros de auditoría avanzados) llevan una licencia empresarial aparte y solo se activan con una clave de pago. La instalación de hoy usa la edición Community, sin esas funciones, y la serie no las necesita: el control de versiones del Día 42 se hará con la exportación JSON y un repositorio propio.

La ejecución como unidad de cobro

El Día 12 (Costes y límites: créditos, tareas, ejecuciones y cuotas) tradujo el flujo de mensajes de Trigo Lento a tres unidades: $7,644$ créditos de Make, $6,084$ tareas de Zapier y $1,560$ ejecuciones de n8n al mes. La diferencia viene de la definición: Make cobra un crédito por cada operación de cada módulo, mientras que n8n Cloud cobra una ejecución por corrida completa. Un agente que, para cada mensaje, clasifica, consulta el catálogo, revisa la agenda del obrador, redacta y pide aprobación hace diez o quince pasos, que en Make serían diez o quince créditos por mensaje y en n8n siguen siendo una ejecución. Cuantos más pasos tiene cada corrida, más conviene contar por ejecuciones. Esa es la razón de fondo por la que el agente vive en n8n.

Y hay una consecuencia que decide el día: en la instalación propia, la ejecución deja de ser una unidad de cobro. n8n Community no limita las ejecuciones; el límite lo pone el servidor. El siguiente bloque fija las importaciones, la semilla de la serie y los supuestos que se usarán en todos los cálculos, incluido el precio del servidor pequeño que el Día 21 actualizó con las tarifas de 2026:

python
# Importaciones base, semilla de la serie y supuestos del día en Python:
import warnings
warnings.filterwarnings('ignore')
import hashlib
import json
import re
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Patch

# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 120)
carpeta = Path('trigo_lento') / 'dia_34'
carpeta.mkdir(parents = True, exist_ok = True)

# Supuestos fijos de la serie (A.4.5, A.5 y Día 21), revisados en octubre de 2026:
USD_POR_EUR, MXN_POR_USD = 1.08, 18.50   # tipos didácticos, no cotizaciones
HORA_VALERIA = 75                        # MXN por hora
MENSAJES_MES = 1560                      # 60 mensajes al día, 26 días al mes
SERVIDOR_EUR = 5.99                      # VPS de 2 vCPU y 4 GB: 5.49 EUR más 0.50 de IPv4 (Día 21)
print('numpy', np.__version__, '| pandas', pd.__version__)
print(f'Servidor: {SERVIDOR_EUR} EUR = {SERVIDOR_EUR * USD_POR_EUR:.2f} USD al mes | '
      f'hora de Valeria: {HORA_VALERIA / MXN_POR_USD:.2f} USD')
numpy 2.4.6 | pandas 3.0.3
Servidor: 5.99 EUR = 6.47 USD al mes | hora de Valeria: 4.05 USD

El servidor de referencia, de $2$ vCPU y $4$ GB de memoria, cuesta $6.47$ USD al mes al tipo didáctico, y cada hora de Valeria vale $4.05$ USD. Con esos dos números se puede comparar cualquier opción de pago con cualquier opción de trabajo propio.

Nube frente a instalación propia


La forma más rápida de entender la diferencia es pensar en vivienda. n8n Cloud es un departamento amueblado: se paga una renta, el arrendador arregla la caldera y repinta, y usted solo decide dónde poner sus cosas. La instalación propia es una casa: no hay renta, pero el tejado que gotea, la cerradura y el seguro son suyos. Ninguna de las dos es mejor en abstracto; depende de cuánto valga su tiempo y de cuánto le importe decidir dónde están sus cosas. La Figura 1 recorre las habitaciones una por una.

Departamento amueblado frente a casa propia: en n8n Cloud el arrendador paga las reparaciones y cobra 25.92 USD al mes; en la instalación propia la renta baja a 6.47 USD, pero la caldera, la cerradura y el domingo que algo falla son de Valeria.

Figura 1. Departamento amueblado frente a casa propia: en n8n Cloud el arrendador paga las reparaciones y cobra 25.92 USD al mes; en la instalación propia la renta baja a 6.47 USD, pero la caldera, la cerradura y el domingo que algo falla son de Valeria.

La comparación de habitación por habitación que muestra la Figura 1 se resume en una lista de diferencias concretas:

  • Coste: en la nube, una suscripción fija que sube por escalones de ejecuciones; en casa, el servidor más las horas de mantenimiento.
  • Mantenimiento: en la nube lo hace n8n; en casa, Valeria (actualizaciones de n8n, de Postgres y del sistema operativo, certificados, espacio en disco).
  • Ubicación de los datos: n8n Cloud aloja las instancias en la Unión Europea (en Fráncfort, sobre la infraestructura de Microsoft Azure, según su página de precios y fuentes secundarias); en casa, donde Valeria contrate el servidor.
  • Actualizaciones: en la nube llegan solas; en casa se eligen, lo que es una ventaja (se puede esperar a que una versión madure) y una obligación (si no se actualiza, se acumulan fallos de seguridad).
  • Escalado: en la nube se cambia de plan; en casa se cambia de servidor o se activa el modo de colas con varios trabajadores, que la serie no necesitará.
  • Soporte: en los planes Starter y Pro, el foro de la comunidad, igual que en la edición Community; el soporte con compromiso de respuesta empieza en los planes empresariales.

El siguiente bloque pone esas diferencias en una tabla, con los precios de octubre de 2026 convertidos a dólares, y calcula qué parte del cupo de ejecuciones consumiría Trigo Lento cuando el módulo 4 esté terminado. Para la previsión se suman el agente de pedidos del Día 38 (una ejecución por mensaje), los recordatorios masivos del Día 41 (una ejecución al día, que recorre todos los encargos dentro de la misma corrida), la facturación semanal de la Cafetería Brújula del Día 40 y una estimación de corridas del manejador de errores:

python
# Comparativa de n8n Cloud Starter, Pro y Community autoalojado en Python:
eur = lambda x: round(x * USD_POR_EUR, 2)
planes = pd.DataFrame({
    'Cloud Starter': {'USD al mes (pago mensual)': eur(24), 'USD al mes (pago anual)': eur(20),
                      'ejecuciones incluidas': 2500, 'ejecuciones simultáneas': 5,
                      'horas de mantenimiento al mes': 0.0, 'datos en': 'UE (Fráncfort)',
                      'actualiza': 'n8n', 'soporte': 'foro'},
    'Cloud Pro': {'USD al mes (pago mensual)': eur(60), 'USD al mes (pago anual)': eur(50),
                  'ejecuciones incluidas': 10000, 'ejecuciones simultáneas': 20,
                  'horas de mantenimiento al mes': 0.0, 'datos en': 'UE (Fráncfort)',
                  'actualiza': 'n8n', 'soporte': 'foro'},
    'Community propio': {'USD al mes (pago mensual)': eur(SERVIDOR_EUR),
                         'USD al mes (pago anual)': eur(SERVIDOR_EUR),
                         'ejecuciones incluidas': np.inf, 'ejecuciones simultáneas': np.inf,
                         'horas de mantenimiento al mes': 2.0, 'datos en': 'servidor elegido',
                         'actualiza': 'Valeria', 'soporte': 'foro'},
})
print(planes.to_string())

# Ejecuciones previstas cuando el módulo 4 esté completo (una ejecución por corrida, tenga los nodos que tenga):
previstas = {'Agente de pedidos (Día 38)': MENSAJES_MES, 'Recordatorios masivos (Día 41)': 26,
             'Facturación de Brújula (Día 40)': 4, 'Manejador de errores': 30}
total = sum(previstas.values())
print('\nEjecuciones previstas al mes:', previstas, '->', total)
for plan in ['Cloud Starter', 'Cloud Pro']:
    cupo = planes.loc['ejecuciones incluidas', plan]
    print(f'{plan}: {total / cupo:.1%} del cupo | mensajes al día que lo agotan: '
          f'{(cupo - total + MENSAJES_MES) / 26:.0f}')
                                Cloud Starter       Cloud Pro  Community propio
USD al mes (pago mensual)               25.92            64.8              6.47
USD al mes (pago anual)                  21.6            54.0              6.47
ejecuciones incluidas                    2500           10000               inf
ejecuciones simultáneas                     5              20               inf
horas de mantenimiento al mes             0.0             0.0               2.0
datos en                       UE (Fráncfort)  UE (Fráncfort)  servidor elegido
actualiza                                 n8n             n8n           Valeria
soporte                                  foro            foro              foro

Ejecuciones previstas al mes: {'Agente de pedidos (Día 38)': 1560, 'Recordatorios masivos (Día 41)': 26, 'Facturación de Brújula (Día 40)': 4, 'Manejador de errores': 30} -> 1620
Cloud Starter: 64.8% del cupo | mensajes al día que lo agotan: 94
Cloud Pro: 16.2% del cupo | mensajes al día que lo agotan: 382

La Tabla 1 recoge el resultado con las cifras de la Tabla B del plan de la serie, contrastadas en octubre de 2026.

Tabla 1. n8n Cloud Starter y Pro frente a Community autoalojado (revisado en octubre de 2026).

AspectoCloud StarterCloud ProCommunity propio
Precio en pago mensual24 EUR (25.92 USD)60 EUR (64.80 USD)0 más el servidor (6.47 USD)
Precio en pago anual, por mes20 EUR (21.60 USD)50 EUR (54.00 USD)0 más el servidor (6.47 USD)
Ejecuciones incluidas al mes2 50010 000Sin límite de licencia
Ejecuciones simultáneas520Las que soporte el servidor
Uso previsto de Trigo Lento64.8 % del cupo16.2 % del cupoNo aplica
Mantenimienton8nn8nValeria, unas 2 horas al mes
Ubicación de los datosUnión Europea (Fráncfort)Unión Europea (Fráncfort)Donde se contrate el servidor
ActualizacionesAutomáticasAutomáticasManuales, cuando Valeria decide
EscaladoCambiar de planCambiar de planCambiar de servidor o modo de colas
SoporteForo de la comunidadForo de la comunidadForo de la comunidad

Tres lecturas de la tabla:

  1. En primer lugar, el cupo de Starter alcanza, pero sin mucha holgura. Las $1,620$ ejecuciones previstas ocupan el $64.8%$ de las $2,500$; si el agente atendiera más de $94$ mensajes al día en promedio, en lugar de los $60$ actuales, el plan se quedaría corto. Un mes de diciembre con encargos de rosca, o un segundo agente para Instagram, bastaría para tener que subir a Pro.
  2. En segundo lugar, Pro sobra para Trigo Lento: con el $16.2%$ de su cupo usado, se pagaría $2.5$ veces el precio de Starter por una capacidad que la panadería no usará este año.
  3. En tercer lugar, la columna de la instalación propia cambia de naturaleza: no tiene cupo ni escalones, pero su precio no está completo hasta sumar las horas de Valeria. Por eso la siguiente sección no compara precios, sino costes totales.

La página de precios de n8n consultada en octubre de 2026 publica los importes del pago anual (20 y 50 EUR al mes) y anuncia un ahorro del $17%$ frente al pago mensual; los 24 y 60 EUR del pago mensual son los que usa la serie desde la Tabla B y coinciden con ese descuento ($20 / 24 = 0.83$). La misma página indica hoy workflows activos ilimitados en los dos planes, un cambio frente a versiones anteriores que limitaban su número.

Coste total de propiedad a 12 meses


El coste total de propiedad (o TCO, por sus siglas en inglés: “Total Cost of Ownership”) suma todo lo que cuesta tener algo funcionando durante un periodo, no solo lo que se paga en la factura. Para una plataforma de automatización tiene tres partes: lo que cuesta ponerla en marcha, lo que se paga cada mes y las horas de las personas que la mantienen. Es la misma fórmula que el Día 11 usó para comparar Make, Zapier, n8n y Python, ahora aplicada solo a n8n:

$$ C(t) = I + t,\left(s + \frac{h_m, v}{c}\right) $$

La lógica detrás de esta fórmula es múltiple:

  • $I$ es la inversión inicial: en la nube vale $0$; en casa, las $6$ horas de instalación (Docker, Postgres, dominio, HTTPS, cortafuegos y copias), que a $75$ MXN la hora y $18.50$ MXN por USD son $24.32$ USD.
  • $s$ es la suscripción o el servidor de cada mes: $25.92$ USD de Starter con pago mensual, o $6.47$ USD del servidor propio.
  • $h_m v / c$ es el mantenimiento mensual convertido a dólares: $h_m$ horas al mes por el valor $v$ de la hora de Valeria, dividido entre el tipo de cambio $c$. Con $2$ horas, son $8.11$ USD al mes.
  • En conclusión, la nube empieza más barata y crece más rápido; la casa empieza con una inversión y crece más despacio. El mes en que se cruzan es $t^{*} = I / \left(s_{nube} - s_{casa} - h_m v / c\right)$.

El pago anual de Starter merece un trato aparte: se paga el año completo por adelantado, $12 \cdot 21.60 = 259.20$ USD el primer día, de modo que su curva es una línea horizontal. El siguiente bloque calcula las cuatro curvas, la tabla a 12 meses, los dos puntos de cruce y las horas de mantenimiento que harían empatar a la casa con Starter. Al final compara el gasto en efectivo de un mes de Trigo Lento con cada opción contra el presupuesto de $45$ USD, usando las cifras del mes simulado del Día 33 para Make Core ($10.59$), Claude ($1.47$) y WhatsApp con Twilio ($21.92$):

python
# Coste total de propiedad a 12 meses: suscripción frente a servidor más horas de Valeria en Python:
hora_usd = HORA_VALERIA / MXN_POR_USD
servidor = eur(SERVIDOR_EUR)
instalacion = 6 * hora_usd                   # Docker, Postgres, dominio, HTTPS, cortafuegos y copias
mantenimiento = 2 * hora_usd                 # actualizaciones y revisión de copias, al mes
meses = np.arange(0, 13)

def coste(inicial, mensual):
    return inicial + mensual * meses          # C(t) = I + t (s + h v)

curvas = {
    'Cloud Starter, pago mensual': coste(0, eur(24)),
    'Cloud Starter, pago anual': np.full(13, 12 * eur(20)),   # se paga el año por adelantado
    'Cloud Pro, pago mensual': coste(0, eur(60)),
    'Community propio': coste(instalacion, servidor + mantenimiento),
}
tabla2 = pd.DataFrame({
    'instalación': [0, 0, 0, instalacion],
    'plataforma 12 m': [12 * eur(24), 12 * eur(20), 12 * eur(60), 12 * servidor],
    'horas 12 m': [0, 0, 0, 12 * mantenimiento],
}, index = curvas.keys())
tabla2['total 12 m'] = tabla2.sum(axis = 1)
tabla2['USD al mes'] = tabla2['total 12 m'] / 12
print(tabla2.round(2).to_string())
propio_mes = servidor + mantenimiento
print(f'\nPropio: {servidor:.2f} + {mantenimiento:.2f} = {propio_mes:.2f} USD al mes, más {instalacion:.2f} de instalación')
for rival, mensual in [('Starter mensual', eur(24)), ('Starter anual', eur(20))]:
    print(f'Cruce con {rival}: mes {instalacion / (mensual - propio_mes):.2f}')
h_max = (eur(24) - servidor - instalacion / 12) / hora_usd
print(f'Horas de mantenimiento al mes que igualan Starter mensual el primer año: {h_max:.2f}')

# El mes con cada opción frente a los 45 USD (Make Core, Claude y WhatsApp del mes simulado del Día 33):
resto = 10.59 + 1.47 + 21.92
for nombre, mensual in [('con Cloud Starter mensual', eur(24)), ('con servidor propio', servidor)]:
    print(f'Gasto en efectivo {nombre}: {resto + mensual:.2f} USD de 45')

fig, ax = plt.subplots(figsize = (9, 5))
colores = ['#2a78d6', '#7fb1ea', '#8a8986', '#1f9d55']
for (nombre, y), color in zip(curvas.items(), colores):
    ax.plot(meses, y, marker = 'o', markersize = 4, linewidth = 2, color = color, label = nombre)
    ax.annotate(f'{y[-1]:.2f}', (12, y[-1]), textcoords = 'offset points', xytext = (6, -3), fontsize = 9)
cruce = instalacion / (eur(24) - propio_mes)
ax.axvline(cruce, color = '#eb6834', linestyle = '--', linewidth = 1.2)
ax.text(cruce + 0.15, 400, f'cruce con Starter\nmensual: mes {cruce:.1f}', fontsize = 9, color = '#eb6834')
ax.set_xlim(0, 13.6)
ax.set_xticks(meses)
ax.set_xlabel('Mes')
ax.set_ylabel('Coste acumulado (USD)')
ax.set_title('Coste acumulado de n8n a 12 meses', fontsize = 16)
ax.legend(frameon = False, loc = 'upper left')
ax.grid(color = '#e6e5e1')
ax.set_axisbelow(True)
plt.tight_layout()
plt.show()
                             instalación  plataforma 12 m  horas 12 m  total 12 m  USD al mes
Cloud Starter, pago mensual         0.00           311.04         0.0      311.04       25.92
Cloud Starter, pago anual           0.00           259.20         0.0      259.20       21.60
Cloud Pro, pago mensual             0.00           777.60         0.0      777.60       64.80
Community propio                   24.32            77.64        97.3      199.26       16.61

Propio: 6.47 + 8.11 = 14.58 USD al mes, más 24.32 de instalación
Cruce con Starter mensual: mes 2.14
Cruce con Starter anual: mes 3.46
Horas de mantenimiento al mes que igualan Starter mensual el primer año: 4.30
Gasto en efectivo con Cloud Starter mensual: 59.90 USD de 45
Gasto en efectivo con servidor propio: 40.45 USD de 45

Gráfica: Coste total de propiedad a 12 meses. Coste total de propiedad a 12 meses: suscripción frente a servidor más horas de Valeria

La Tabla 2 resume el resultado a 12 meses.

Tabla 2. Coste total de n8n a 12 meses para Trigo Lento (USD, horas de Valeria a 75 MXN).

OpciónInstalaciónPlataforma 12 mesesHoras 12 mesesTotal 12 mesesPor mes
Cloud Starter, pago mensual0.00311.040.00311.0425.92
Cloud Starter, pago anual0.00259.200.00259.2021.60
Cloud Pro, pago mensual0.00777.600.00777.6064.80
Community propio24.3277.6497.30199.2616.61

El servidor propio cuesta $199.26$ USD el primer año frente a $311.04$ de Starter con pago mensual y $259.20$ con pago anual, aun contando cada hora de Valeria a precio de mercado. La gráfica muestra por qué: la curva verde arranca por encima de la azul (los $24.32$ USD de la instalación), la alcanza en el mes $2.14$ y desde ahí se separa cada mes por $11.34$ USD. Contra el pago anual, el cruce llega en el mes $3.46$. Cloud Pro queda fuera de la discusión: cuesta casi cuatro veces lo que la casa.

Hay dos cifras del bloque que valen más que la tabla:

  1. El umbral de $4.30$ horas al mes. Si el mantenimiento real del servidor superara $4.30$ horas mensuales en promedio durante el primer año, Starter con pago mensual resultaría más barato. Es la cifra que Valeria debe vigilar: no el precio del servidor, que es fijo, sino su propio tiempo, que no lo es. La primera vez que un mes pase de cuatro horas, la decisión de hoy debe revisarse.
  2. El efectivo del mes. Con Cloud Starter, el gasto en efectivo de Trigo Lento sería de $59.90$ USD, $14.90$ por encima de los $45$ del presupuesto; con el servidor propio, $40.45$ USD. Las horas de Valeria no salen de la caja de la panadería, pero los $25.92$ USD de la suscripción sí, y en marzo no hay de dónde sacarlos mientras la mensajería siga desbordada.

Ese segundo punto es el que convierte una preferencia en una necesidad. Con el presupuesto de $45$ USD y la mensajería de Twilio, n8n Cloud no cabe; el servidor propio sí. El módulo 5 bajará la mensajería con la Cloud API directa de Meta, que además necesita un servidor propio para recibir sus webhooks, de modo que la instalación de hoy también prepara esa mudanza.

Atención aquí: el coste de las horas es una estimación, no una medición, y suele quedarse corto. Las dos horas al mes suponen un mes tranquilo: una actualización, una revisión de las copias y un vistazo a los registros. Un incidente (un disco lleno, una actualización que rompe un nodo, un certificado que no se renovó) puede costar una tarde entera. Lleve un registro de las horas reales en registro_ejecuciones.csv desde el primer día y compare cada trimestre con el umbral de $4.30$ horas; sin ese registro, el TCO de la instalación propia es una opinión.

Instalar con Docker Compose


Docker empaqueta cada programa con todo lo que necesita en un contenedor, y Docker Compose describe en un solo archivo varios contenedores que trabajan juntos, con sus redes y sus volúmenes. Para Trigo Lento hacen falta tres: n8n, la base de datos Postgres y un proxy inverso que reciba las peticiones de Internet con HTTPS y se las pase a n8n. La Figura 2 muestra la arquitectura completa, desde Internet hasta las copias de seguridad.

Arquitectura del despliegue de Trigo Lento: el cortafuegos solo abre 22, 80 y 443; Caddy recibe con HTTPS y pasa a n8n por la red interna; n8n guarda en Postgres; tres volúmenes, el archivo .env y una copia diaria fuera del servidor; si n8n cae, Twilio usa la URL de respaldo en Make.

Figura 2. Arquitectura del despliegue de Trigo Lento: el cortafuegos solo abre 22, 80 y 443; Caddy recibe con HTTPS y pasa a n8n por la red interna; n8n guarda en Postgres; tres volúmenes, el archivo .env y una copia diaria fuera del servidor; si n8n cae, Twilio usa la URL de respaldo en Make.

Las piezas de la Figura 2 tienen cada una su razón:

  • Proxy inverso con HTTPS (Caddy). Es el único contenedor que escucha en los puertos públicos $80$ y $443$. Obtiene y renueva solo el certificado de Let’s Encrypt y reenvía las peticiones a n8n por la red interna de Docker. La guía oficial de n8n usa Traefik para lo mismo; la serie elige Caddy porque su configuración cabe en una línea.
  • n8n. Escucha en el puerto $5678$, pero publicado solo en 127.0.0.1, de modo que desde Internet no se puede llegar a él sin pasar por el proxy.
  • Postgres. La base de datos guarda los workflows, las credenciales cifradas y el historial de ejecuciones. n8n usa SQLite por omisión, un archivo dentro de su carpeta de datos que basta para probar; Postgres soporta mejor la concurrencia, las copias en caliente con pg_dump y el crecimiento del historial, y es lo que la serie usará desde hoy.
  • Volúmenes. Tres volúmenes con nombre (postgres_data, n8n_data y caddy_data) guardan lo que debe sobrevivir a la recreación de un contenedor: los datos, la carpeta de configuración de n8n y los certificados.

El siguiente archivo es el docker-compose.yml de la panadería. Los valores secretos no están en él: se leen del archivo .env, que vive solo en el servidor. La imagen de n8n lleva la versión fijada, 2.43.7, y no latest, para que una actualización sea siempre una decisión y nunca una sorpresa de un reinicio:

yaml
# Instalación propia de n8n para Trigo Lento: n8n, Postgres y Caddy (HTTPS), archivo docker-compose.yml:
# Las variables ${...} se leen del archivo .env, que vive solo en el servidor (permisos 600) y nunca en el repositorio.
services:
  postgres:
    image: postgres:16
    restart: always
    environment:
      POSTGRES_DB: n8n
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: docker.n8n.io/n8nio/n8n:2.43.7      # versión fijada: se cambia a mano, nunca latest
    restart: always
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "127.0.0.1:5678:5678"                   # solo accesible desde el propio servidor; entra por Caddy
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}  # cifra las credenciales guardadas en Postgres
      N8N_HOST: ${N8N_HOST}
      N8N_PORT: 5678
      N8N_PROTOCOL: https
      N8N_WEBHOOK_URL: https://${N8N_HOST}/      # desde n8n 2.30.0; en versiones anteriores, WEBHOOK_URL
      GENERIC_TIMEZONE: America/Mexico_City      # zona de los disparadores programados
      TZ: America/Mexico_City
      NODE_ENV: production
      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
    volumes:
      - n8n_data:/home/node/.n8n

  caddy:
    image: caddy:2
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro     # una línea: n8n.trigolento.example { reverse_proxy n8n:5678 }
      - caddy_data:/data                        # certificados de Let's Encrypt

volumes:
  postgres_data:
  n8n_data:
  caddy_data:

Las variables de entorno del servicio n8n son las que más errores provocan cuando faltan. La lista siguiente dice qué pasa si se olvida cada una, que es la forma más rápida de recordarlas:

  • N8N_ENCRYPTION_KEY: cifra las credenciales (las claves de API, los tokens de OAuth) antes de guardarlas en Postgres. Si no se fija, n8n genera una al primer arranque y la guarda en su volumen; si ese volumen se pierde, las credenciales de la base ya no se pueden descifrar. El Día 35 dedica una entrada entera a esta clave.
  • N8N_WEBHOOK_URL: la dirección pública con la que n8n construye las URL de sus webhooks. Sin ella, el nodo Webhook muestra direcciones con localhost, que ningún servicio externo puede alcanzar. Desde n8n 2.30.0 se llama así; en versiones anteriores, WEBHOOK_URL.
  • GENERIC_TIMEZONE y TZ: la zona horaria. Sin ellas, el contenedor vive en UTC y un recordatorio programado “a las 8:00” sale a las 2:00 de la Ciudad de México. America/Mexico_City no tiene horario de verano desde 2022, así que la diferencia con UTC es de seis horas todo el año.
  • DB_TYPE y las DB_POSTGRESDB_*: sin ellas, n8n arranca con SQLite y guarda todo en un archivo que nadie respalda con pg_dump.
  • N8N_PROTOCOL y N8N_HOST: con ellas, los enlaces que n8n genera (en correos de invitación, en la interfaz) llevan https y el dominio correcto.

Con el archivo listo, la instalación son pocos comandos. El siguiente bloque genera los secretos del .env con openssl, deja el cortafuegos con solo tres puertos abiertos (SSH, HTTP y HTTPS), arranca los contenedores, comprueba el estado y la ruta /healthz, y muestra los dos procedimientos que Valeria repetirá: la actualización, que siempre empieza con una exportación, y la exportación de todos los workflows a la terminal:

bash
# Arranque, actualización y exportación de n8n en el servidor de Trigo Lento, en bash:
cd /opt/trigo_lento

# 1. Secretos en .env (solo en el servidor; la clave se copia también al gestor de contraseñas):
umask 077
{
  echo "N8N_HOST=n8n.trigolento.example"
  echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
  echo "N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)"
} > .env
echo 'n8n.trigolento.example { reverse_proxy n8n:5678 }' > Caddyfile

# 2. Cortafuegos: solo SSH, HTTP y HTTPS; el 5678 y el 5432 no se publican:
sudo ufw default deny incoming
sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
sudo ufw enable

# 3. Arranque y comprobación:
docker compose up -d
docker compose ps
docker compose logs --tail 50 n8n
curl -I https://n8n.trigolento.example/healthz

# 4. Actualización: primero exportar, después cambiar la etiqueta en docker-compose.yml y relanzar:
docker compose exec -u node n8n n8n export:workflow --backup --output=/home/node/.n8n/respaldos/$(date +%F)/
docker compose pull
docker compose up -d
docker image prune -f

# 5. Exportación de todos los workflows a la terminal (lo que muestra el Bloque 10):
docker compose exec -u node n8n n8n export:workflow --all --pretty

Tres detalles del bloque son decisiones de seguridad, no de comodidad. El umask 077 antes de escribir el .env hace que el archivo nazca con permisos solo para su dueño. La política del cortafuegos empieza por negar todo lo entrante y luego abre lo imprescindible: ni el $5678$ de n8n ni el $5432$ de Postgres quedan publicados. Y el paso 4 exporta los workflows antes de cambiar de versión, porque una actualización que migra la base es el momento en que más falta hace una copia reciente.

La instalación, con el dominio y la primera cuenta de propietario, es lo que el cálculo del TCO estimó en $6$ horas. El Día 11 había supuesto $10$ horas para n8n self-hosted porque contaba también la construcción del primer flujo; hoy esas horas se separan: seis para la casa y las demás para cada workflow que se mude a ella.

Disponibilidad


Un servidor propio se cae. No es una posibilidad, es una certeza estadística: el proveedor reinicia el equipo físico para aplicar un parche, el disco se llena con el historial de ejecuciones, una actualización trae un error, el contenedor se queda sin memoria. La disponibilidad es la fracción del tiempo en que el servicio funciona, y se expresa en porcentajes con nueves: $99%$, $99.5%$, $99.9%$. El libro de ingeniería de fiabilidad de Google (Beyer y colaboradores, 2016) insiste en traducir esos porcentajes a minutos, porque un $99%$ suena casi perfecto y no lo es:

$$ D = (1 - A)\cdot 43,200 $$

La lógica detrás de esta fórmula es sencilla:

  • $A$ es la disponibilidad, entre $0$ y $1$; $1 - A$ es la fracción del tiempo con el servicio caído.
  • $43,200$ son los minutos de un mes de $30$ días ($30 \cdot 24 \cdot 60$); el resultado $D$ son los minutos de caída permitidos al mes.
  • Con $A = 0.995$, $D = 216$ minutos, las $3.6$ horas que estimó el plan de la serie.
  • En conclusión, cada nueve adicional divide la caída permitida entre diez, y cuesta bastante más que diez veces conseguirlo.

Los minutos, por sí solos, todavía no dicen nada a Don Ernesto. Lo que importa es cuántos mensajes de clientes llegan mientras el servidor está caído. Si los $1,560$ mensajes del mes se repartieran de forma uniforme, los expuestos serían $1,560 \cdot (1 - A)$. Pero no se reparten así: entre las 10:00 y las 12:00 llegan $9$ por hora, y una caída que empiece a las 10:00 de un martes encuentra el doble de mensajes que una a las 16:00. El siguiente bloque calcula los minutos, las horas y los mensajes expuestos por nivel, más el peor caso: toda la caída del mes concentrada en una mañana de pico, desde las 10:00:

python
# Minutos de caída al mes por nivel de disponibilidad y mensajes expuestos en Python:
MINUTOS_MES = 30 * 24 * 60                   # 43 200 minutos en un mes de 30 días
niveles = np.array([0.99, 0.995, 0.999])

def peor_manana(minutos):
    # Caída que empieza a las 10:00: 9 mensajes por hora en el pico de 2 h y 4.2 por hora después:
    pico = np.minimum(minutos, 120)
    return pico * 9 / 60 + np.maximum(minutos - 120, 0) * 4.2 / 60

caida = (1 - niveles) * MINUTOS_MES
tabla3 = pd.DataFrame({'disponibilidad': [f'{a:.1%}' for a in niveles],
                       'min/mes': caida, 'h/mes': caida / 60, 'h/año': caida * 12 / 60,
                       'mensajes expuestos/mes': (1 - niveles) * MENSAJES_MES,
                       'peor mañana de pico': peor_manana(caida)})
print(tabla3.round(1).to_string(index = False))

fig, ax = plt.subplots(figsize = (8, 3.8))
barras = ax.barh(tabla3['disponibilidad'], caida, color = ['#c8423b', '#eb6834', '#1f9d55'], height = 0.55)
for b, m in zip(barras, caida):
    ax.text(m + 6, b.get_y() + b.get_height() / 2, f'{m:.0f} min ({m / 60:.1f} h)', va = 'center', fontsize = 10)
ax.invert_yaxis()
ax.set_xlim(0, 520)
ax.set_xlabel('Minutos de caída al mes (30 días)')
ax.set_title('Minutos de caída al mes por nivel de disponibilidad', fontsize = 16)
ax.grid(axis = 'x', color = '#e6e5e1')
ax.set_axisbelow(True)
plt.tight_layout()
plt.show()
disponibilidad  min/mes  h/mes  h/año  mensajes expuestos/mes  peor mañana de pico
         99.0%    432.0    7.2   86.4                    15.6                 39.8
         99.5%    216.0    3.6   43.2                     7.8                 24.7
         99.9%     43.2    0.7    8.6                     1.6                  6.5

Gráfica: Disponibilidad. Minutos de caída al mes por nivel de disponibilidad y mensajes expuestos

La Tabla 3 reúne las cifras.

Tabla 3. Disponibilidad, minutos de caída al mes y mensajes de clientes expuestos.

DisponibilidadMinutos al mesHoras al mesHoras al añoMensajes expuestos al mesPeor mañana de pico
99 %432.07.286.415.639.8
99.5 %216.03.643.27.824.7
99.9 %43.20.78.61.66.5

Con un $99.5%$, el servidor de Trigo Lento puede estar caído $3.6$ horas al mes y, en promedio, eso expone a $7.8$ mensajes. Es poco frente a $1,560$, pero el peor caso cuenta otra historia: si esas $3.6$ horas cayeran juntas un martes desde las 10:00, serían $24.7$ mensajes sin respuesta en la mañana más ocupada, casi tres horas de pico de Valeria. La disponibilidad media no dice cuándo llega la caída, y para una panadería el cuándo importa tanto como el cuánto.

Qué pasa con los webhooks si el servidor cae

Un mensaje de WhatsApp no llega a n8n por arte de magia: el proveedor (Twilio hoy, la Cloud API de Meta desde el módulo 5) hace una petición POST al webhook de n8n. Si el servidor está caído, esa petición falla, y lo que ocurre después depende del emisor, no de Valeria:

  • Twilio sin URL de respaldo: la documentación de Twilio describe un error 11200 cuando el webhook no responde o responde con un código distinto de 2xx. Si el número no tiene configurada una URL de respaldo (en la consola, el campo “Primary handler fails”), el mensaje no se procesa y se pierde para el flujo.
  • Twilio con URL de respaldo: Twilio hace de inmediato la misma petición a una segunda URL, idealmente en otra infraestructura. Trigo Lento tiene una a mano: el webhook del escenario TL Pedidos WhatsApp v3 de Make, que seguirá activo hasta la migración del Día 45 (Sesión práctica de consolidación: migrar el escenario de Make a n8n). Mientras n8n esté caído, Make responde como respondía ayer.
  • Cloud API de Meta: según su documentación, Meta reintenta durante un máximo de $7$ días las notificaciones de webhook que no se entregaron. Los mensajes no se pierden; llegan tarde, y el flujo debe ser idempotente para no procesar dos veces el que se reintentó justo cuando el servidor volvía (la lección del Día 29).

Falta responder a Don Ernesto, y su pregunta no es sobre el promedio, sino sobre el domingo. Una caída automática (el contenedor se detiene y Docker lo reinicia con restart: always) dura minutos. Una caída que necesita a una persona (el disco lleno, una actualización rota) dura hasta que alguien se entera y la arregla, y si empieza un sábado a las 22:00, nadie la arregla hasta el lunes. El siguiente bloque simula $1,000$ meses con ese modelo. Los mensajes llegan de lunes a sábado de 8:00 a 20:00, con el pico de 10:00 a 12:00; cada mes hay en promedio $3$ caídas automáticas (de unos $3$ minutos, con duración lognormal) y $1$ caída manual, que solo empieza a repararse cuando Valeria está disponible y tarda entonces unos $45$ minutos más. Los mensajes afectados en cada caída se calculan con la tasa de llegada de cada minuto:

$$ E[M] = \int_{t_0}^{t_1} \lambda(t),dt $$

La lógica detrás de esta fórmula es múltiple:

  • $\lambda(t)$ es la tasa de llegada de mensajes en el minuto $t$: $9/60$ en el pico, $4.2/60$ el resto del horario y $0$ por la noche y los domingos.
  • La integral entre el inicio $t_0$ y el fin $t_1$ de la caída es el número esperado de mensajes que llegan mientras el servidor no responde; el bloque la calcula con una suma acumulada por minuto y sortea el número real con una distribución de Poisson.
  • Una caída de diez horas un domingo puede tener $E[M] = 0$, y una de veinte minutos un martes a las 10:30, $E[M] = 3$.
  • En conclusión, los minutos de caída miden al servidor; los mensajes afectados miden a la panadería.
python
# Caídas simuladas en 1 000 meses y mensajes de clientes afectados en Python:
DIAS = 30                                    # el día 0 es lunes; los días 6, 13, 20 y 27 son domingo

def tasa(minuto):
    # Mensajes por minuto: lunes a sábado de 8:00 a 20:00, con el pico de 10:00 a 12:00:
    dia, hora = divmod(minuto, 1440)
    if dia % 7 == 6 or not 480 <= hora < 1200:
        return 0.0
    return 9 / 60 if 600 <= hora < 720 else 4.2 / 60

perfil = np.array([tasa(m) for m in range(DIAS * 1440)])
acumulado = np.concatenate([[0], np.cumsum(perfil)])

def siguiente_atencion(minuto):
    # Primer minuto en que Valeria puede atender: lunes a sábado de 8:00 a 20:00:
    while tasa(int(minuto) % (DIAS * 1440)) == 0 and minuto < DIAS * 1440 + 2880:
        minuto = (minuto // 60 + 1) * 60
    return minuto

def simular_mes(rng):
    caidas = []
    for _ in range(rng.poisson(3)):           # se cae el contenedor: Docker lo reinicia solo
        inicio = rng.uniform(0, DIAS * 1440)
        caidas.append((inicio, inicio + rng.lognormal(np.log(3), 0.6), 'automática'))
    for _ in range(rng.poisson(1)):           # disco lleno, actualización rota: hace falta Valeria
        inicio = rng.uniform(0, DIAS * 1440)
        caidas.append((inicio, siguiente_atencion(inicio) + rng.lognormal(np.log(45), 0.5), 'manual'))
    detalle = []
    for inicio, fin, clase in caidas:
        fin = min(fin, DIAS * 1440)
        mensajes = rng.poisson(acumulado[int(fin)] - acumulado[int(inicio)])
        detalle.append({'inicio': inicio, 'minutos': fin - inicio, 'clase': clase, 'mensajes': mensajes})
    return detalle

meses_sim = [simular_mes(rng) for _ in range(1000)]
minutos = np.array([sum(c['minutos'] for c in m) for m in meses_sim])
afectados = np.array([sum(c['mensajes'] for c in m) for m in meses_sim])
largas = sum(c['minutos'] for m in meses_sim for c in m if c['minutos'] > 240)
espera = sum(c['mensajes'] * c['minutos'] / 2 for m in meses_sim for c in m) / afectados.sum()
disponibilidad = 1 - minutos / (DIAS * 1440)
print(f'Disponibilidad media: {disponibilidad.mean():.3%} | p5: {np.percentile(disponibilidad, 5):.3%}')
print(f'Minutos de caída al mes: media {minutos.mean():.0f} | mediana {np.median(minutos):.0f} | p95 {np.percentile(minutos, 95):.0f}')
print(f'Mensajes afectados al mes: media {afectados.mean():.1f} | p95 {np.percentile(afectados, 95):.0f} | máx {afectados.max()}')
print(f'Meses sin ningún mensaje afectado: {(afectados == 0).mean():.1%}')
print(f'Caídas de más de 4 h (noches y domingos sin nadie): {largas / minutos.sum():.1%} de los minutos caídos')
print(f'Twilio sin URL de respaldo: se pierden {afectados.mean():.1f} mensajes al mes')
print('Twilio con URL de respaldo en Make: se pierden 0; Make responde con el escenario v3')
print(f'Cloud API de Meta (reintenta hasta 7 días): llegan tarde, {espera:.0f} min de retraso medio')

fig, ejes = plt.subplots(1, 2, figsize = (11, 4.2), gridspec_kw = {'width_ratios': [1.6, 1]})
malo = meses_sim[int(np.argsort(afectados)[900])]       # un mes malo: percentil 90
dias = np.arange(DIAS)
ejes[0].bar(dias, [acumulado[(d + 1) * 1440] - acumulado[d * 1440] for d in dias], color = '#d9e6f7')
for c in malo:
    d = int(c['inicio'] // 1440)
    color = '#c8423b' if c['clase'] == 'manual' else '#eb6834'
    ejes[0].bar(d, max(c['mensajes'], 0.6), color = color, width = 0.8)
    ejes[0].text(d, max(c['mensajes'], 0.6) + 1.5, f"{c['minutos']:.0f} min", ha = 'center', fontsize = 8)
ejes[0].legend(handles = [Patch(color = '#d9e6f7', label = 'mensajes esperados'),
                          Patch(color = '#eb6834', label = 'afectados: caída automática'),
                          Patch(color = '#c8423b', label = 'afectados: caída manual')],
               frameon = False, fontsize = 8, loc = 'upper right')
ejes[0].set_ylim(0, 85)
ejes[0].set_title(f"Un mes del percentil 90: {sum(c['minutos'] for c in malo):.0f} min de caída", fontsize = 11)
ejes[0].set_xlabel('Día del mes (domingos: 6, 13, 20 y 27)')
ejes[0].set_ylabel('Mensajes')
ejes[1].hist(afectados, bins = np.arange(0, afectados.max() + 2) - 0.5, color = '#2a78d6', edgecolor = 'white')
ejes[1].axvline(afectados.mean(), color = '#eb6834', linestyle = '--', linewidth = 2)
ejes[1].text(afectados.mean() + 0.6, ejes[1].get_ylim()[1] * 0.85, f'media {afectados.mean():.1f}', color = '#eb6834')
ejes[1].set_xlabel('Mensajes afectados en el mes')
ejes[1].set_ylabel('Meses (de 1 000)')
ejes[1].set_title('Mensajes afectados por mes', fontsize = 11)
fig.suptitle('Caídas simuladas del servidor propio y mensajes de clientes', fontsize = 16)
plt.tight_layout()
plt.show()
Disponibilidad media: 99.084% | p5: 95.741%
Minutos de caída al mes: media 396 | mediana 76 | p95 1840
Mensajes afectados al mes: media 4.2 | p95 14 | máx 29
Meses sin ningún mensaje afectado: 28.8%
Caídas de más de 4 h (noches y domingos sin nadie): 87.2% de los minutos caídos
Twilio sin URL de respaldo: se pierden 4.2 mensajes al mes
Twilio con URL de respaldo en Make: se pierden 0; Make responde con el escenario v3
Cloud API de Meta (reintenta hasta 7 días): llegan tarde, 177 min de retraso medio

Gráfica: Qué pasa con los webhooks si el servidor cae. Caídas simuladas en 1 000 meses y mensajes de clientes afectados

La simulación separa con claridad dos ideas que el promedio mezclaba:

  1. La disponibilidad media es de $99.08%$, peor que el $99.5%$ supuesto, y la culpa es de las caídas que esperan a una persona: el $87.2%$ de los minutos caídos viene de caídas de más de cuatro horas, las que empiezan de noche o en domingo. La mediana de minutos caídos al mes es de solo $76$, pero la media sube a $396$ y el percentil $95$ a $1,840$, más de un día entero. Don Ernesto tenía razón: el problema del servidor propio es el domingo.
  2. Los mensajes afectados son pocos porque esas caídas largas ocurren cuando nadie escribe. En promedio, $4.2$ mensajes al mes; en el $28.8%$ de los meses, ninguno; en el peor de los mil meses, $29$. El histograma de la derecha muestra la cola larga: la mayoría de los meses están entre $0$ y $5$, y unos pocos llegan a $15$ o $20$, cuando una caída manual empieza en horario de atención.

El panel izquierdo enseña un mes del percentil $90$: dos caídas largas, de $532$ y $316$ minutos, que juntas afectaron a una decena de mensajes, y varias automáticas de minutos que no tocaron a ninguno. Con la URL de respaldo en Make, esos mensajes no se pierden: los atiende el escenario v3. Con la Cloud API de Meta llegarían tarde, con un retraso medio de $177$ minutos, lo que obliga a un mensaje de disculpa automático al volver. Sin respaldo, $4.2$ mensajes al mes se perderían, que es lo único inaceptable de los tres.

El modelo de caídas es de juguete y sus parámetros son supuestos declarados: tres caídas automáticas y una manual al mes es un servidor descuidado, no uno típico. Su valor está en la forma del resultado (la media la dominan las caídas fuera de horario, los mensajes los dominan las caídas en horario de pico) más que en las cifras exactas. Con un monitor externo que avise al teléfono de Valeria, la parte de las caídas manuales que hoy espera al lunes se acortaría a lo que tarde en ver el aviso.

La decisión de Valeria


Con los números sobre la mesa, Don Ernesto repite su pregunta, ahora más afilada: "¿Y quién arregla el servidor un domingo?" La respuesta honesta es que nadie, y que la simulación dice que casi no importa porque el domingo nadie escribe; pero también dice que una caída que empieza el sábado por la noche puede comerse la mañana del lunes. La decisión no puede depender solo del coste. Valeria la plantea como el Día 11, con una matriz de decisión ponderada: cada opción $j$ recibe una nota $x_{ij}$ de $1$ a $5$ en cada criterio $i$, cada criterio tiene un peso $w_i$, y la puntuación es la suma ponderada:

$$ S_j = \sum_{i} w_i, x_{ij}, \qquad \sum_i w_i = 1 $$

La lógica detrás de esta fórmula es múltiple:

  • Las notas $x_{ij}$ describen las opciones y deberían ser las mismas para cualquiera que las mire con los datos de hoy: el coste sale de la Tabla 2, la disponibilidad del Bloque 7, el margen para agentes de los cupos de la Tabla 1.
  • Los pesos $w_i$ describen a quien decide; no son datos, son preferencias, y por eso se escriben a la vista.
  • Como los pesos suman $1$, $S_j$ queda en la misma escala de $1$ a $5$ que las notas.
  • En conclusión, cuando dos personas discrepan, la matriz permite saber si discrepan en los hechos (las notas) o en lo que les importa (los pesos).

Los cinco criterios son el coste a 12 meses, la carga de mantenimiento, el control de los datos, la disponibilidad y el margen para agentes (cuántas ejecuciones y cuánta concurrencia quedan para crecer). Valeria escribe sus pesos; Don Ernesto, que no quiere ser él quien llame a nadie un domingo, escribe los suyos, con el mantenimiento en $0.35$. El siguiente bloque calcula las dos puntuaciones, busca el peso del mantenimiento con el que empatan la casa y Starter, y repite la decisión con $5,000$ vectores de pesos sorteados alrededor de los de Valeria (una distribución de Dirichlet con concentración $40$, que genera pesos positivos que suman $1$ y se parecen a los originales):

python
# Matriz de decisión ponderada con los pesos de Valeria y los de Don Ernesto en Python:
criterios = ['coste a 12 meses', 'carga de mantenimiento', 'control de los datos',
             'disponibilidad', 'margen para agentes']
notas = pd.DataFrame({            # de 1 (malo) a 5 (bueno)
    'Cloud Starter':    [2, 5, 3, 5, 3],
    'Cloud Pro':        [1, 5, 3, 5, 5],
    'Community propio': [4, 2, 5, 3, 5]}, index = criterios)
pesos = pd.DataFrame({
    'Valeria':     [0.30, 0.20, 0.20, 0.15, 0.15],
    'Don Ernesto': [0.25, 0.35, 0.15, 0.15, 0.10]}, index = criterios)
assert np.allclose(pesos.sum(), 1)
puntos = pesos.T @ notas                         # S_j = suma de w_i por x_ij
print(pd.concat([notas, pesos], axis = 1).to_string())
print('\nPuntuación ponderada:')
print(puntos.round(2).to_string())

# ¿Con qué peso del mantenimiento empata el servidor propio con Starter? (el resto se reescala):
def puntuacion(w_mant, base):
    w = base.copy() * (1 - w_mant) / (1 - base['carga de mantenimiento'])
    w['carga de mantenimiento'] = w_mant
    return w @ notas
w = np.linspace(0, 0.6, 6001)
diferencia = np.array([puntuacion(x, pesos['Valeria'])['Community propio'] -
                       puntuacion(x, pesos['Valeria'])['Cloud Starter'] for x in w])
print(f"Empate cuando el mantenimiento pesa {w[np.argmax(diferencia <= 0)]:.3f}")

# 5 000 vectores de pesos alrededor de los de Valeria (Dirichlet con concentración 40):
muestras = rng.dirichlet(40 * pesos['Valeria'].to_numpy(), 5000)
ganador = pd.Series(notas.columns[np.argmax(muestras @ notas.to_numpy(), axis = 1)]).value_counts(normalize = True)
print('Gana en los 5 000 vectores:', {k: f'{v:.1%}' for k, v in ganador.items()})
                        Cloud Starter  Cloud Pro  Community propio  Valeria  Don Ernesto
coste a 12 meses                    2          1                 4     0.30         0.25
carga de mantenimiento              5          5                 2     0.20         0.35
control de los datos                3          3                 5     0.20         0.15
disponibilidad                      5          5                 3     0.15         0.15
margen para agentes                 3          5                 5     0.15         0.10

Puntuación ponderada:
             Cloud Starter  Cloud Pro  Community propio
Valeria               3.40        3.4               3.8
Don Ernesto           3.75        3.7               3.4
Empate cuando el mantenimiento pesa 0.294
Gana en los 5 000 vectores: {'Community propio': '83.4%', 'Cloud Pro': '10.1%', 'Cloud Starter': '6.5%'}

La Tabla 4 reúne la matriz completa.

Tabla 4. Matriz de decisión ponderada: notas de 1 a 5, pesos de Valeria y de Don Ernesto y puntuación.

CriterioCloud StarterCloud ProCommunity propioPeso de ValeriaPeso de Don Ernesto
Coste a 12 meses2140.300.25
Carga de mantenimiento5520.200.35
Control de los datos3350.200.15
Disponibilidad5530.150.15
Margen para agentes3550.150.10
Puntuación con los pesos de Valeria3.403.403.80
Puntuación con los pesos de Don Ernesto3.753.703.40

Las dos personas miran los mismos hechos y llegan a conclusiones opuestas: con los pesos de Valeria gana la casa con $3.80$; con los de Don Ernesto gana Starter con $3.75$. El bloque precisa dónde está la frontera: la casa gana mientras el mantenimiento pese menos de $0.294$. Valeria le dio $0.20$; Don Ernesto, $0.35$. Y en los $5,000$ vectores de pesos cercanos a los de Valeria, la casa gana en el $83.4%$, Pro en el $10.1%$ y Starter en el $6.5%$: la decisión es razonablemente estable mientras nadie cambie de opinión sobre lo que vale una tarde de domingo.

La matriz no resuelve el desacuerdo, pero lo hace preciso: no discuten si el servidor es más barato (lo es), sino cuánto pesa la carga de mantenerlo. Y eso se puede negociar con hechos. Valeria propone la casa con cuatro condiciones que bajan esa carga, y Don Ernesto acepta con una quinta:

  1. URL de respaldo en Make. Mientras exista el escenario v3, el número de Twilio apunta a Make como respaldo; ninguna caída de n8n pierde mensajes.
  2. Monitor externo. Un servicio de comprobación externo consulta /healthz cada cinco minutos y avisa al teléfono de Valeria; una caída del sábado por la noche se ve el domingo por la mañana, no el lunes.
  3. Ventana de mantenimiento. Las actualizaciones se hacen el lunes a las 16:00, la hora más tranquila, nunca un viernes ni antes de un fin de semana largo.
  4. Copias fuera del servidor. Siete copias diarias y cuatro semanales, con la última siempre fuera de la máquina (la sección siguiente y el Día 35).
  5. La condición de Don Ernesto: un umbral de salida. Si dos meses seguidos el mantenimiento pasa de $4.30$ horas, o si una caída afecta a la Cafetería Brújula, la panadería se muda a Cloud Starter sin discusión. La exportación JSON hace que esa mudanza sea de una tarde.

Valeria lo anota en el registro de decisiones del Día 21, junto a la decisión 11 del Día 11 (“Make para mensajes, n8n self-hosted para el agente, Python como laboratorio”), que tenía pendiente la revisión “al llegar el servidor”. El servidor llegó, y la decisión se confirma con fecha de caducidad.

Seguridad del servidor


Decidir la casa es aceptar sus tareas. Mientras Trigo Lento vivía en Make, la seguridad de la plataforma era trabajo de Make; desde hoy, el servidor que guarda los mensajes y los teléfonos de los clientes es responsabilidad directa de la panadería, y la Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP) obliga a quien trata datos personales a adoptar medidas de seguridad administrativas, técnicas y físicas. No basta con que el servidor funcione: tiene que estar cuidado, y cada cuidado necesita un nombre al lado. La Figura 3 es esa lista.

La lista de responsabilidades del servidor de n8n: seis tareas, cada una con su responsable, su suplente y su frecuencia, y el umbral de salida que pidió Don Ernesto: más de 4.30 horas al mes dos meses seguidos lleva a Cloud Starter.

Figura 3. La lista de responsabilidades del servidor de n8n: seis tareas, cada una con su responsable, su suplente y su frecuencia, y el umbral de salida que pidió Don Ernesto: más de 4.30 horas al mes dos meses seguidos lleva a Cloud Starter.

La lista de responsabilidades de la Figura 3 se convierte en la Tabla 5, que Valeria imprime y pega junto a la libreta de pedidos.

Tabla 5. Lista de responsabilidades del servidor de n8n de Trigo Lento.

ResponsabilidadQué incluyeFrecuenciaResponsableSuplente
ActualizacionesImagen de n8n con versión fijada, Postgres y parches del sistema operativoMensual, lunes 16:00ValeriaNinguno (se pospone)
Copias de seguridadpg_dump diario a las 03:00, exportación de workflows, copia fuera del servidorDiaria y automática; revisión semanalValeriaDon Ernesto revisa el aviso
MonitoreoComprobación externa de /healthz cada 5 minutos con aviso al teléfonoContinuoValeriaDon Ernesto recibe el aviso
AccesoCuenta de propietario con contraseña larga y segundo factor; SSH solo con llaveAl dar de alta o de baja a alguienValeriaNinguno
Secretos.env con permisos 600; N8N_ENCRYPTION_KEY también en el gestor de contraseñasAl instalar y al rotarValeriaDon Ernesto conoce el gestor
CortafuegosSolo los puertos 22, 80 y 443 abiertosAl instalar; revisión trimestralValeriaNinguno

Cuatro reglas resumen la tabla:

  • Actualizar es parte del servicio. Una instancia de n8n sin actualizar acumula vulnerabilidades conocidas, y n8n publica versiones con frecuencia. La versión fijada en el docker-compose.yml no es para no actualizar nunca, sino para actualizar cuando se elige, con una exportación previa.
  • La cuenta de propietario es la llave maestra. El primer usuario que se registra en una instancia nueva se convierte en propietario. Por eso la instancia no debe quedar expuesta entre el docker compose up y la creación de esa cuenta: Valeria la crea en el mismo minuto.
  • Puertos cerrados por omisión. Lo que no está publicado no se puede atacar desde fuera: Postgres solo habla con n8n por la red interna de Docker.
  • La clave de cifrado nunca va al repositorio. El docker-compose.yml se versiona; el .env, jamás.

Atención aquí: al autoalojar, Trigo Lento pasa a ser el responsable directo de los datos de sus clientes que guarda el historial de ejecuciones: nombres, teléfonos y el texto de los mensajes. Eso exige tres cosas que no son técnicas: anotar en el aviso de privacidad dónde está el servidor y quién es el proveedor, limitar cuánto tiempo se guarda el historial (la poda de ejecuciones que configura el Día 35) y cifrar las copias que salen del servidor. Y una que sí lo es: N8N_ENCRYPTION_KEY debe estar guardada en un gestor de contraseñas fuera del servidor. Si el servidor desaparece con la única copia de la clave, las copias de la base conservan los workflows, pero ninguna credencial guardada se podrá volver a descifrar.

La política de copias

La copia de seguridad es la responsabilidad que más se descuida porque no se nota hasta el día que falta. Valeria fija una política sencilla, de las que se usan en servidores pequeños: siete copias diarias y cuatro semanales. Cada noche a las 03:00 se hace una copia; se conservan las siete últimas, y además la del domingo de cada una de las cuatro últimas semanas. Así, un error que se descubre al día siguiente se corrige con la copia de ayer, y uno que pasó inadvertido tres semanas todavía tiene a qué volver.

Antes de automatizarla, conviene simularla para saber cuántas copias habrá a la vez, cuánto ocuparán y hasta dónde se puede retroceder. El siguiente bloque simula $60$ días de copias, desde la primera, el sábado 7 de marzo a las 03:00, con un tamaño de unos $14$ MB que crece poco a poco con el historial, y aplica la rotación cada día:

python
# Política de copias de seguridad (7 diarias y 4 semanales) simulada sobre 60 días en Python:
inicio = date(2026, 3, 7)                        # primera copia: sábado 7 de marzo a las 03:00
copias = pd.DataFrame({'fecha': [inicio + timedelta(days = d) for d in range(60)]})
copias['tamano_mb'] = 14.2 + 0.03 * np.arange(60) + rng.normal(0, 0.15, 60)   # crece poco a poco
copias['domingo'] = [f.weekday() == 6 for f in copias['fecha']]

def retenidas(copias, hoy, diarias = 7, semanales = 4):
    hechas = copias[copias['fecha'] <= hoy]
    ultimas = set(hechas['fecha'].tail(diarias))
    domingos = set(hechas.loc[hechas['domingo'], 'fecha'].tail(semanales))
    return sorted(ultimas | domingos)

historial = [len(retenidas(copias, f)) for f in copias['fecha']]
final = retenidas(copias, copias['fecha'].iloc[-1])
peso = copias.set_index('fecha').loc[final, 'tamano_mb'].sum()
print(f'Copias hechas: {len(copias)} | retenidas el día 60 ({final[-1]}): {len(final)} | ocupan {peso:.1f} MB')
print('Retenidas:', ', '.join(f.strftime('%d/%m') for f in final))
print(f'Copia más antigua disponible: {(final[-1] - final[0]).days} días atrás')
print('Copias retenidas por día (primeros 15):', historial[:15])
print('Máximo de copias retenidas a la vez:', max(historial))

fig, ax = plt.subplots(figsize = (10, 2.8))
guardada = copias['fecha'].isin(final)
diaria, semanal = guardada & ~copias['domingo'], guardada & copias['domingo']
ax.scatter(copias['fecha'], np.zeros(60), s = 40, color = '#d6d5d1', label = 'borrada por la rotación')
ax.scatter(copias.loc[diaria, 'fecha'], np.zeros(diaria.sum()), s = 70, color = '#2a78d6', label = 'diaria retenida')
ax.scatter(copias.loc[semanal, 'fecha'], np.zeros(semanal.sum()), s = 110, marker = 's', color = '#1f9d55',
           label = 'semanal retenida (domingo)')
ax.set_yticks([])
ax.set_ylim(-1, 1.2)
ax.legend(frameon = False, ncol = 3, loc = 'upper center', fontsize = 9)
ax.set_title(f'Copias retenidas el día 60: {len(final)} de {len(copias)}', fontsize = 16)
ax.grid(axis = 'x', color = '#e6e5e1')
plt.tight_layout()
plt.show()
Copias hechas: 60 | retenidas el día 60 (2026-05-05): 10 | ocupan 157.1 MB
Retenidas: 12/04, 19/04, 26/04, 29/04, 30/04, 01/05, 02/05, 03/05, 04/05, 05/05
Copia más antigua disponible: 23 días atrás
Copias retenidas por día (primeros 15): [1, 2, 3, 4, 5, 6, 7, 7, 8, 8, 8, 8, 8, 8, 8]
Máximo de copias retenidas a la vez: 10

Gráfica: La política de copias. Política de copias de seguridad (7 diarias y 4 semanales) simulada sobre 60 días

El resultado tiene una sorpresa pequeña: la política guarda $10$ copias, no $11$. La copia del domingo más reciente es a la vez una de las siete diarias y una de las cuatro semanales, y un conjunto no cuenta dos veces el mismo elemento. Las diez copias ocupan $157.1$ MB, y la más antigua tiene $23$ días: es el horizonte más lejano al que Valeria puede volver. La lista de los primeros días muestra cómo se llena: una copia el primer día, siete al séptimo, y de ahí sube poco a poco, un domingo cada vez, hasta estabilizarse en diez.

La gráfica deja ver la forma de la retención: un grupo apretado de copias diarias al final y, hacia atrás, los cuadrados verdes de los domingos, separados una semana. Todo lo gris se borró. Una copia que no se ha restaurado nunca es una esperanza, no una copia: el Día 35 repartirá estas diez copias en tres lugares con la regla 3-2-1 y hará un simulacro de restauración con cronómetro.

La primera exportación

La exportación de workflows es la otra mitad del respaldo: no guarda el historial ni las credenciales, pero guarda el diseño de cada workflow en un JSON legible, que se puede versionar y que sirve para mudarse de instancia, incluso a n8n Cloud si se cumple la condición de Don Ernesto. Para comprobar la instalación de punta a punta, Valeria crea un workflow mínimo, TL Prueba de humo v1: un webhook que recibe un POST, un nodo Edit Fields (n8n-nodes-base.set) que prepara una respuesta con el estado, la hora local y la zona horaria, y un nodo Respond to Webhook que la devuelve. Si la respuesta trae America/Mexico_City y la hora correcta, el proxy, el certificado, la URL de webhooks y la zona horaria están bien a la vez.

Este es el JSON que devuelve n8n export:workflow --backup para ese workflow, con los identificadores sustituidos por valores de ejemplo como manda la regla A.4.3 de la serie:

json
{
  "name": "TL Prueba de humo v1",
  "nodes": [
    {
      "parameters": {"httpMethod": "POST", "path": "prueba-humo", "responseMode": "responseNode", "options": {}},
      "id": "0b1c0000-0000-4000-8000-000000000001",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 0],
      "webhookId": "00000000-0000-4000-8000-0000000000aa"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {"id": "a1000000-0000-4000-8000-000000000001", "name": "estado", "value": "ok", "type": "string"},
            {"id": "a1000000-0000-4000-8000-000000000002", "name": "hora_local", "value": "={{ $now.toISO() }}", "type": "string"},
            {"id": "a1000000-0000-4000-8000-000000000003", "name": "zona", "value": "={{ $now.zoneName }}", "type": "string"},
            {"id": "a1000000-0000-4000-8000-000000000004", "name": "eco", "value": "={{ $json.body.eco }}", "type": "string"}
          ]
        },
        "options": {}
      },
      "id": "7f3a0000-0000-4000-8000-000000000002",
      "name": "Preparar respuesta",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [220, 0]
    },
    {
      "parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}},
      "id": "9c2d0000-0000-4000-8000-000000000003",
      "name": "Responder",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [440, 0]
    }
  ],
  "connections": {
    "Webhook": {"main": [[{"node": "Preparar respuesta", "type": "main", "index": 0}]]},
    "Preparar respuesta": {"main": [[{"node": "Responder", "type": "main", "index": 0}]]}
  },
  "active": false,
  "settings": {"executionOrder": "v1", "timezone": "America/Mexico_City"},
  "versionId": "5e7f0000-0000-4000-8000-000000000034",
  "pinData": {},
  "meta": {"templateCredsSetupCompleted": true},
  "tags": []
}

El JSON tiene las dos partes que el Día 36 estudiará con detalle: nodes, con el tipo, la versión del tipo (typeVersion), la posición en el lienzo y los parámetros de cada nodo, y connections, indexada por el nombre del nodo de origen. Fíjese en que el webhook usa "responseMode": "responseNode": no responde al recibir, sino cuando el flujo llega al nodo Responder, lo que permite devolver el resultado del propio workflow. En settings, la zona horaria se fija también por workflow, una segunda defensa por si algún día se olvida la variable del contenedor.

Un JSON exportado solo sirve como copia si se revisa. El siguiente bloque lo lee y comprueba tres cosas: que cada conexión apunte a un nodo que existe, que no haya nada con forma de secreto (una clave de API, una cabecera de autorización, una contraseña) y que la zona horaria sea la de la panadería. Después calcula una huella SHA-256 del workflow sin los campos que cambian cada vez que se guarda (versionId, active y las fechas), para que dos exportaciones del mismo diseño den la misma huella; la misma idea servirá el Día 35 para comprobar una restauración. Prueba también una copia alterada a propósito y dibuja el workflow con las formas de la serie, leído directamente del JSON:

python
# Revisión, huella y dibujo del workflow exportado en Python:
workflow = json.loads((carpeta / 'flujos' / 'n8n' / 'prueba_de_humo_v1.json').read_text(encoding = 'utf-8'))

def revisar_workflow(wf):
    hallazgos = []
    nombres = {n['name'] for n in wf['nodes']}
    for origen, salidas in wf['connections'].items():
        if origen not in nombres:
            hallazgos.append(f'conexión desde un nodo inexistente: {origen}')
        for tipo, lista in salidas.items():
            for destinos in lista:
                hallazgos += [f"destino inexistente: {d['node']}" for d in destinos if d['node'] not in nombres]
    texto = json.dumps(wf, ensure_ascii = False)
    if re.search(r'sk-ant-|x-api-key|Bearer |password', texto, re.I):
        hallazgos.append('posible secreto en el JSON')
    if wf.get('settings', {}).get('timezone') != 'America/Mexico_City':
        hallazgos.append('zona horaria distinta de America/Mexico_City')
    return hallazgos

def huella(wf):
    # Sin los campos que cambian en cada guardado, para comparar antes y después de restaurar:
    limpio = {k: v for k, v in wf.items() if k not in ('versionId', 'updatedAt', 'createdAt', 'active')}
    return hashlib.sha256(json.dumps(limpio, sort_keys = True, ensure_ascii = False).encode()).hexdigest()

FORMAS = {'webhook': ('disparador', '#1f9d55'), 'set': ('accion', '#2a78d6'),
          'respondToWebhook': ('accion', '#2a78d6'), 'if': ('decision', '#eb6834')}

def nodos_desde_n8n(wf):
    nodos = [{'id': n['name'], 'tipo': n['type'].split('.')[-1],
              'x': n['position'][0] / 100, 'y': -n['position'][1] / 100} for n in wf['nodes']]
    aristas = [(o, d['node'], tipo) for o, s in wf['connections'].items()
               for tipo, lista in s.items() for destinos in lista for d in destinos]
    return nodos, aristas

def dibujar_flujo(nodos, aristas, titulo):
    fig, ax = plt.subplots(figsize = (9, 2.6))
    pos = {n['id']: (n['x'], n['y']) for n in nodos}
    for n in nodos:
        forma, color = FORMAS[n['tipo']]
        estilo = 'round,pad=0.05,rounding_size=0.35' if forma == 'disparador' else 'round,pad=0.05'
        ax.add_patch(FancyBboxPatch((n['x'] - 0.8, n['y'] - 0.35), 1.6, 0.7, boxstyle = estilo,
                                    facecolor = color, edgecolor = 'white', linewidth = 2))
        ax.text(n['x'], n['y'] + 0.1, n['id'], ha = 'center', va = 'center', color = 'white', fontsize = 10)
        ax.text(n['x'], n['y'] - 0.15, n['tipo'], ha = 'center', va = 'center', color = 'white', fontsize = 8)
    for o, d, tipo in aristas:
        (x1, y1), (x2, y2) = pos[o], pos[d]
        ax.add_patch(FancyArrowPatch((x1 + 0.86, y1), (x2 - 0.86, y2), arrowstyle = '-|>', mutation_scale = 14,
                                     color = '#52514e', linewidth = 1.4))
    ax.set_xlim(-1, max(p[0] for p in pos.values()) + 1)
    ax.set_ylim(-0.7, 0.7)
    ax.set_aspect('equal')
    ax.axis('off')
    ax.set_title(titulo, fontsize = 14)
    return ax

print('Nodos:', [(n['name'], n['type'].split('.')[-1], n['typeVersion']) for n in workflow['nodes']])
print('Hallazgos:', revisar_workflow(workflow) or 'ninguno')
h1 = huella(workflow)
otra = dict(workflow, versionId = 'otra-version', active = True)
print('Huella:', h1[:16], '| igual tras cambiar versionId y active:', huella(otra) == h1)
roto = json.loads(json.dumps(workflow))
roto['connections']['Webhook']['main'][0][0]['node'] = 'Responder al cliente'
roto['nodes'][1]['parameters']['options'] = {'cabecera': 'Bearer sk-ant-XXXX'}
print('Hallazgos en una copia alterada:', revisar_workflow(roto))
print('Huella de la copia alterada:', huella(roto)[:16])
dibujar_flujo(*nodos_desde_n8n(workflow), f"{workflow['name']}, leído del JSON exportado")
plt.tight_layout()
plt.show()
Nodos: [('Webhook', 'webhook', 2), ('Preparar respuesta', 'set', 3.4), ('Responder', 'respondToWebhook', 1.1)]
Hallazgos: ninguno
Huella: eb15cc9b57a1c673 | igual tras cambiar versionId y active: True
Hallazgos en una copia alterada: ['destino inexistente: Responder al cliente', 'posible secreto en el JSON']
Huella de la copia alterada: 12a9078d7098939d

Gráfica: La primera exportación. Revisión, huella y dibujo del workflow exportado

El workflow tiene tres nodos y ningún hallazgo: las conexiones apuntan a nodos que existen, no hay secretos y la zona horaria es la correcta. La huella empieza por eb15cc9b57a1c673 y no cambia aunque el workflow se active o se guarde con otra versión, que es justo lo que se pide a una huella de diseño. La copia alterada, en cambio, delata sus dos defectos: una conexión hacia un nodo que no existe (“Responder al cliente”) y una cabecera con forma de clave de API; su huella, 12a9078d7098939d, ya no coincide. En el dibujo, el webhook aparece como disparador en óvalo verde y los dos pasos como acciones en rectángulo azul, la paleta de la serie desde el Día 2.

Consideraciones finales


El día empezó con una comparación de Don Ernesto, el horno que no se paga al mes y que se descompone en domingo, y termina dándole la razón en lo que la tenía. El servidor propio es más barato: $199.26$ USD el primer año frente a $311.04$ de Starter, y $40.45$ USD de gasto en efectivo al mes frente a $59.90$, que no caben en el presupuesto de $45$. Pero esa ventaja se compra con tiempo y con riesgo: se pierde si el mantenimiento pasa de $4.30$ horas al mes, y la disponibilidad real de un servidor sin vigilancia (un $99.08%$ en la simulación) la deciden las caídas que nadie atiende de noche y en domingo. Autoalojar no es ahorrarse una cuota: es cambiar una cuota por una lista de responsabilidades, y la lista tiene que tener nombres.

Antes de dar por buena la decisión, conviene hacerse algunas preguntas:

  • ¿Cuántas horas al mes dedicó de verdad al servidor? Si no lo sabe, no sabe si la casa sigue siendo más barata que el departamento.
  • ¿Qué le pasa a un mensaje de un cliente si su servidor se cae ahora mismo? Si la respuesta es “se pierde”, le falta una URL de respaldo o un emisor que reintente.
  • ¿Dónde está la clave de cifrado, además de en el servidor? Si la respuesta es “en ningún otro sitio”, su próxima copia de seguridad está incompleta.
  • ¿Qué condición le haría volver a la nube? Una decisión sin umbral de salida no se revisa nunca.

Las cifras de hoy tienen límites claros. Los precios de n8n Cloud se publican en euros y se convirtieron al tipo didáctico de la serie; el servidor de $6.47$ USD es un supuesto de un proveedor concreto que puede cambiar; el modelo de caídas es de juguete, con parámetros declarados, y los mensajes llegan con un perfil horario simplificado. La matriz ponderada, además, depende de notas que otra persona podría poner distintas. Ninguno de esos límites cambia la forma de las conclusiones: la nube es más cara y más tranquila, la casa es más barata y más exigente, y la diferencia la decide el tiempo de quien la cuida.

Un gran libro que habla de esto es Site Reliability Engineering: How Google Runs Production Systems, editado por Betsy Beyer, Chris Jones, Jennifer Petoff y Niall Richard Murphy. Está escrito para sistemas enormes, pero sus capítulos sobre objetivos de nivel de servicio, presupuestos de error y la eliminación del trabajo repetitivo (lo que el libro llama toil) se aplican sin cambios a un servidor de seis dólares: decidir cuánta caída se tolera, medirla y no gastar en fiabilidad lo que el negocio no necesita. Leerlo después de esta entrada le ayudará a escribir su propia lista de responsabilidades con menos optimismo.

Conclusiones clave


Licencia y planes

  • n8n se publica con la Sustainable Use License: se permite el uso interno de negocio, incluidos los clientes que reciben lo que producen los workflows; no se permite vender un servicio cuyo valor sea sustancialmente n8n.
  • n8n Cloud cobra por ejecución, una por corrida completa, tenga los nodos que tenga: Starter incluye $2,500$ al mes y Pro $10,000$ (revisado en octubre de 2026).
  • Las $1,620$ ejecuciones previstas de Trigo Lento ocupan el $64.8%$ de Starter; con más de $94$ mensajes al día, el plan se queda corto. Community no limita ejecuciones.

Coste

  • El coste total $C(t) = I + t(s + h_m v / c)$ separa la instalación, la plataforma y las horas: a 12 meses, la casa cuesta $199.26$ USD, Starter mensual $311.04$, Starter anual $259.20$ y Pro $777.60$.
  • La casa alcanza a Starter en el mes $2.14$ y deja de convenir si el mantenimiento supera $4.30$ horas al mes.
  • En efectivo, Cloud Starter llevaría el mes a $59.90$ USD frente a los $45$ del presupuesto; el servidor propio lo deja en $40.45$.

Operación

  • Docker Compose con n8n en la versión $2.43.7$, Postgres y Caddy con HTTPS; N8N_ENCRYPTION_KEY, N8N_WEBHOOK_URL y GENERIC_TIMEZONE=America/Mexico_City en un .env fuera del repositorio, y solo los puertos 22, 80 y 443 abiertos.
  • Un $99.5%$ son $216$ minutos de caída al mes; en la simulación, las caídas que esperan a una persona dominan los minutos ($87.2%$) y bajan la media a $99.08%$, pero afectan a solo $4.2$ mensajes al mes porque ocurren cuando nadie escribe.
  • Con la URL de respaldo en Make no se pierde ningún mensaje; la política de 7 copias diarias y 4 semanales guarda $10$ copias, $157.1$ MB y $23$ días de historia, y cada responsabilidad lleva un nombre.

Mañana, en el Día 35: Seguridad II: credenciales cifradas, respaldo y restauración de n8n. Desde esta noche, Trigo Lento tiene copias diarias de su n8n, pero ninguna se ha restaurado todavía; Valeria descubrirá qué protege exactamente la clave de cifrado, repartirá las copias con la regla 3-2-1, fijará cuántos datos se pueden perder y cuánto se puede tardar en volver, y cronometrará un simulacro de restauración en un servidor limpio.

Referencias


  1. n8n (2026). Documentación: alojamiento propio con Docker Compose y variables de entorno. https://docs.n8n.io/hosting/
  2. n8n (2026). Sustainable Use License y preguntas frecuentes de la licencia. https://docs.n8n.io/n8n-community-license/sustainable-use-license
  3. n8n (2026). Comandos de la CLI: exportar e importar workflows. https://docs.n8n.io/hosting/cli-commands/
  4. n8n (2026). Precios de n8n Cloud. https://n8n.io/pricing/
  5. Docker (2026). Documentación de Docker Compose. https://docs.docker.com/compose/
  6. Twilio (2026). Error 11200 y URL de respaldo de los webhooks. https://www.twilio.com/docs/api/errors/11200
  7. Meta (2026). WhatsApp Business Platform: entrega de webhooks y reintentos. https://developers.facebook.com/docs/whatsapp
  8. Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (Eds.) (2016). Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media.
comments powered by Disqus