# ¿Pueden Vercel Daemons sustituir un Worker en VPS? Límites, SIGTERM y configuración

> Canonical: https://vxi.me/es/posts/vercel-daemons-vps-background-worker/
> Locale: es-es
> Published: 2026-10-16
> Updated: 2026-10-16
> Markdown: https://vxi.me/es/posts/vercel-daemons-vps-background-worker.md

Si tienes un Worker en un VPS consultando una cola continuamente, quizá te hayas preguntado por qué sigues administrando un servidor entero. Al fin y al cabo, los trabajos vienen de una cola externa y sus resultados acaban en una base de datos. Los nuevos Daemons de Vercel prometen ejecutar un contenedor de forma persistente con un número fijo de instancias.

Pero **eso no convierte a Daemons en un sustituto directo de un VPS**. Tras revisar el [anuncio de Vercel](https://vercel.com/blog/backends-on-vercel-python-containers-and-daemons), los [límites oficiales](https://vercel.com/docs/daemons/limits) y la [gestión de instancias](https://vercel.com/docs/daemons/managing-daemons), la distinción es clara: un consumidor que extrae trabajos, guarda su estado fuera del contenedor y tolera los reintentos es candidato. Un proceso que requiere puertos entrantes, archivos locales persistentes o un entorno completo de CI no se puede trasladar sin más.

El 15 de octubre de 2026, Vercel anunció Python para backends en GA, Container Functions y Daemons Public Beta. El análisis de la plataforma se basa en su documentación y JSON Schema; por separado, ejecuté un experimento local con Node.js y señales reales \`SIGTERM\` para mostrar qué sucede cuando un trabajo se confirma antes o después de producir sus efectos. No es una prueba de un Daemon ejecutándose en Vercel.

## Responder peticiones HTTP no equivale a consumir una cola

Para una API HTTP hecha con FastAPI, Flask o Django, Python Functions (GA) usa el modelo de ejecución por petición. Si necesitas una imagen OCI propia o bibliotecas del sistema, Container Functions (Beta) puede servir un endpoint HTTP y escalar con las solicitudes. Daemons, en cambio, mantiene una cantidad fija de procesos de larga duración que extraen trabajos de colas o bases de datos. Según la [documentación oficial](https://vercel.com/docs/daemons), actualmente no admite conexiones HTTP/TCP entrantes.

| Trabajo existente | Modelo candidato | Pregunta antes de migrar |
| --- | --- | --- |
| API pública FastAPI / Flask | Python Functions (GA) | Duración de petición, dependencias, conexiones |
| Servicio OCI que recibe HTTP | Container Functions (Beta) | Escalado por solicitudes y precio de Functions |
| Consumidor de cola con base de datos externa | Daemons (Public Beta) | Instancias fijas, estado externo, reintentos e idempotencia |
| Servicio que depende de archivos o caché locales persistentes | VPS u otro host con volumen persistente | Daemons no tiene almacenamiento local persistente |
| Servicio con puerto TCP de entrada | VPS u hosting TCP compatible | Daemons no expone puertos entrantes |

También sería precipitado migrar directamente un GitHub Self-hosted Runner. Aunque el Runner obtiene tareas por iniciativa propia, estas pueden requerir construir contenedores, un directorio de trabajo, caché y permisos sensibles de despliegue. **Compartir el patrón pull no garantiza compatibilidad de sistema de archivos, aislamiento o privilegios.** Hay que examinar cada workflow real.

## Diez vCPU-hours al mes no equivalen a un Worker gratuito permanente

Los [límites Beta](https://vercel.com/docs/daemons/limits) asignan a Hobby **10 vCPU-hours por equipo y mes**. Una instancia de 1 vCPU funcionando continuamente consume la cuota en unas diez horas. Una vez alcanzado el límite, los Daemons del equipo se detienen hasta el mes siguiente o hasta cambiar de plan.

La unidad representa *número de vCPU asignadas × horas en funcionamiento*, no el porcentaje efectivo de uso. Un Worker que pasa casi todo el tiempo esperando mensajes tampoco es un servicio gratuito 24/7. Tampoco se debe trasladar sin comprobarlo el concepto de facturación Active CPU de Container Functions a los Daemons.

En Pro, el límite concurrente por defecto es **16 vCPU y 32 GiB de RAM**: no es una tarifa mensual en dólares. Para comparar costes con un VPS hacen falta los recursos reservados, la duración de ejecución, los costes de red y cola y el trabajo de mantenimiento.

La Beta tampoco incluye Preview Deployments, escalado automático ni disco local persistente; \`vercel dev\` no inicia directamente los Daemons. Estos arrancan con el despliegue de producción y durante el desarrollo local debes ejecutar el contenedor tú mismo. Si dependes de entornos Preview o cachés en disco, esa diferencia puede resultar más problemática que el código. Hay otras restricciones sobre directorios raíz anidados en los [límites](https://vercel.com/docs/daemons/limits).

## El problema tras SIGTERM: cuándo se confirma el trabajo

La [guía de gestión](https://vercel.com/docs/daemons/managing-daemons) indica que, al desplegar una versión nueva, pueden coincidir brevemente los Daemons antiguos y nuevos. El proceso anterior recibe \`SIGTERM\` y dispone por defecto de diez segundos para finalizar. El asunto importante es qué ocurre con el trabajo a medio hacer.

Supongamos que el Worker confirma un cambio en la base de datos y termina antes de enviar Ack a la cola. Si el mensaje vuelve a entregarse, podrías repetir el efecto. Invierte el orden —Ack primero, efecto después— y un cierre prematuro puede dejar un mensaje marcado como procesado aunque la operación nunca se realizara.

Para ilustrarlo ejecuté una pequeña cola sintética **en Node.js v22.16.0 y Linux x64**. Un archivo JSON guarda una tarea ficticia y sus efectos simulados; varios procesos hijos de Node ejecutan el trabajo y reciben realmente \`SIGTERM\` en tres escenarios de interrupción. No mide la fiabilidad ni la latencia de Vercel: contrasta el orden de los efectos y el Ack.

| Escenario local sintético | Intentos | Efectos registrados | Ack final |
| --- | ---: | ---: | --- |
| Ejecutar efecto y luego confirmar | 1 | 1 | Sí |
| Interrupción tras el efecto y reintento con deduplicación por ID | 2 | 1 | Sí |
| Interrupción tras el efecto y reintento **sin** deduplicación | 2 | **2** | Sí |
| Confirmar **antes** de ejecutar y sufrir una interrupción | 1 | **0** | Sí |

Los cuatro casos produjeron el resultado esperado. Los dos controles negativos enseñan el riesgo: **sin deduplicación se repite el efecto, y confirmar demasiado pronto puede perder el trabajo**.

La comprobación principal ocupa unas pocas líneas; \`effects\` son registros de nuestro modelo sintético:

```js
function recordEffect(s, dedupe) {
  const existing = s.effects.some((effect) => effect.jobId === s.job.id);
  if (dedupe && existing) return 'effect_deduplicated';
  s.effects.push({ jobId: s.job.id, effect: 'synthetic-committed-action' });
  return 'effect_applied';
}
```

Con deduplicación por \`jobId\`, el segundo intento detecta un efecto existente y no lo añade de nuevo. Sin esa condición lo duplica. En el caso de Ack anticipado, el Worker recuperado encuentra \`acked: true\` y omite la tarea aunque el efecto todavía no exista.

Las colas reales añaden timeouts de visibilidad, transacciones y reglas de reentrega que este archivo JSON no reproduce. Antes de migrar hay que introducir claves de negocio únicas o escrituras idempotentes, y verificar los reintentos con la cola que se vaya a utilizar.

## La configuración es corta; entender sus garantías no tanto

La [referencia de configuración](https://vercel.com/docs/daemons/config-reference) describe un Worker de una sola región como este. Dentro de \`worker/\` necesitarás además el Dockerfile y el consumidor real de la cola:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "daemons": {
    "worker": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "instances": { "iad1": 1 },
      "resources": { "requests": { "memory": "2Gi" } }
    }
  }
}
```

\`instances\` fija la cantidad por región; \`iad1\` es el identificador de la región de Washington. No activa el escalado automático.

El [JSON Schema del proyecto](https://openapi.vercel.sh/vercel.json) todavía acepta la propiedad antigua \`replicas\`, pero está marcada como deprecated. Si se especifican ambas, prevalece \`instances\`. La guía de gestión y la referencia de configuración actuales ya usan \`instances\`, que es la opción adecuada para proyectos nuevos.

El plazo de \`SIGTERM\` documentado es de diez segundos por defecto, y el Schema incluye \`terminationGracePeriodSeconds\`. Ampliar ese plazo no arregla la duplicación de efectos ni un orden incorrecto de confirmación. Primero hay que diseñar trabajos reintentables y seguros ante una interrupción; después comprobar la configuración en un despliegue real.

Para Workers ligeros que consumen una cola externa, guardan su estado fuera del contenedor y toleran un número fijo de instancias, probaría Daemons primero con tareas no críticas. Mantendría el VPS si necesito directorios persistentes, privilegios complejos para CI, puertos entrantes o un entorno Preview estable. Cambiar el alojamiento de un Runner tampoco elimina el riesgo de ejecutar código no fiable junto a secretos de despliegue.

Puedes ampliar ese contexto con la [cadena de publicación ChatGPT Web + GitHub + Self-hosted Runner](/posts/chatgpt-web-github-self-hosted-runner/) o la [prueba de PanelAlpha Engine en un VPS](/posts/panelalpha-engine-vps-hosting-control-plane/).

Para repetir los cuatro escenarios locales, guarda el siguiente código como \`queue-interruption-l1.mjs\` y ejecútalo con Node.js 20 o posterior mediante \`node queue-interruption-l1.mjs\`. Imprimirá un JSON con los resultados.

<details>
<summary>Ver el código completo de la prueba sintética con Node.js</summary>

```js
/**
 * First-party, offline, synthetic queue acknowledgement experiment.
 * Not a Vercel deployment, Vercel Daemon integration, or platform reliability test.
 * Dependencies: Node.js >= 20 standard library, no network, no cloud credentials.
 * Run: node queue-interruption-l1.mjs
 */
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT = fileURLToPath(import.meta.url);
const JOB = 'synthetic-job-001';
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const readState = (file) => JSON.parse(readFileSync(file, 'utf8'));
function writeState(file, state) {
  const temp = `${file}.next`;
  writeFileSync(temp, JSON.stringify(state) + '\n', { mode: 0o600 });
  renameSync(temp, file);
}
function newState(file) {
  writeState(file, { job: { id: JOB, acked: false }, effects: [], attempt: 0 });
}
function recordEffect(s, dedupe) {
  const existing = s.effects.some((effect) => effect.jobId === s.job.id);
  if (dedupe && existing) return 'effect_deduplicated';
  s.effects.push({ jobId: s.job.id, effect: 'synthetic-committed-action' });
  return 'effect_applied';
}

// The worker is our own short-lived child process, not a provider/runtime process.
function worker(mode, stateFile) {
  const state = readState(stateFile);
  if (state.job.acked) {
    process.send({ phase: 'already_acknowledged' }, () => process.exit(0));
    return;
  }
  state.attempt += 1;
  if (mode === 'ack-before-effect') {
    state.job.acked = true;
    writeState(stateFile, state);
    process.send({ phase: 'ack_committed_effect_missing' });
    process.on('message', () => {}); // Keep child alive until real SIGTERM.
    return;
  }
  if (mode === 'effect-before-ack') {
    recordEffect(state, false);
    writeState(stateFile, state);
    process.send({ phase: 'effect_committed_ack_missing' });
    process.on('message', () => {}); // Keep child alive until real SIGTERM.
    return;
  }
  if (mode === 'complete' || mode === 'complete-without-dedupe') {
    const action = recordEffect(state, mode === 'complete');
    state.job.acked = true;
    writeState(stateFile, state);
    process.send({ phase: action }, () => process.exit(0));
    return;
  }
  throw Error(`Unknown first-party worker mode: ${mode}`);
}

async function runChild(mode, stateFile, expectedPhase, interrupt) {
  return await new Promise((resolve, reject) => {
    const child = fork(SCRIPT, ['--worker', mode, stateFile], {
      env: { PATH: process.env.PATH ?? '', NODE_ENV: 'test' },
      stdio: ['ignore', 'ignore', 'pipe', 'ipc']
    });
    let seen = null;
    let settled = false;
    let stderr = '';
    child.stderr.setEncoding('utf8');
    child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-1200); });
    const timer = setTimeout(() => {
      child.kill('SIGKILL');
      if (!settled) { settled = true; reject(Error(`Worker timeout: ${mode}`)); }
    }, 7000);
    child.on('error', (error) => {
      clearTimeout(timer);
      if (!settled) { settled = true; reject(error); }
    });
    child.on('message', (event) => {
      if (seen !== null) return;
      seen = event.phase;
      if (interrupt) child.kill('SIGTERM');
    });
    child.on('exit', (code, signal) => {
      clearTimeout(timer);
      if (settled) return;
      settled = true;
      if (seen !== expectedPhase || (interrupt ? signal !== 'SIGTERM' : code !== 0)) {
        reject(Error(`Worker mismatch mode=${mode} seen=${seen} code=${code} signal=${signal}; stderr=${stderr}`));
      } else {
        resolve({ phase: seen, exit_code: code, exit_signal: signal });
      }
    });
  });
}

async function experiment() {
  const started = new Date().toISOString();
  const dir = mkdtempSync(path.join(tmpdir(), 'offline-queue-l1-'));
  const cases = [];
  let cleanup = false;
  try {
    const stateFile = path.join(dir, 'state.json');
    newState(stateFile);
    const normal = await runChild('complete', stateFile, 'effect_applied', false);
    let s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, true);
    cases.push({ id: 'control_effect_then_ack', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, worker: normal });

    newState(stateFile);
    const interruptedSafe = await runChild('effect-before-ack', stateFile, 'effect_committed_ack_missing', true);
    s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, false);
    const replaySafe = await runChild('complete', stateFile, 'effect_deduplicated', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, true);
    assert.equal(s.attempt, 2);
    cases.push({ id: 'sigterm_replay_with_dedupe', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, workers: [interruptedSafe, replaySafe] });

    newState(stateFile);
    const interruptedUnsafe = await runChild('effect-before-ack', stateFile, 'effect_committed_ack_missing', true);
    const replayUnsafe = await runChild('complete-without-dedupe', stateFile, 'effect_applied', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 2); // Deliberate negative control: duplicate side effect.
    assert.equal(s.job.acked, true);
    cases.push({ id: 'sigterm_replay_without_dedupe_negative_control', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, observed_risk: 'duplicate_side_effect', workers: [interruptedUnsafe, replayUnsafe] });

    newState(stateFile);
    const earlyAck = await runChild('ack-before-effect', stateFile, 'ack_committed_effect_missing', true);
    const recovery = await runChild('complete', stateFile, 'already_acknowledged', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 0);
    assert.equal(s.job.acked, true);
    assert.equal(s.attempt, 1);
    cases.push({ id: 'ack_before_effect_negative_control', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, observed_risk: 'lost_work', workers: [earlyAck, recovery] });
  } finally {
    rmSync(dir, { recursive: true, force: true });
    cleanup = !existsSync(dir);
  }
  assert.equal(cases.length, 4);
  assert.ok(cases.every((entry) => entry.passed));
  assert.equal(cleanup, true);
  const receipt = {
    schema_version: 1,
    type: 'FIRST_PARTY_OFFLINE_SYNTHETIC_QUEUE_EXPERIMENT',
    scope: 'LOCAL_NODE_CHILD_PROCESSES_ONLY_NOT_VERCEL',
    source_sha256: sha256(readFileSync(SCRIPT)),
    node: process.version,
    platform: process.platform,
    architecture: process.arch,
    started_at: started,
    ended_at: new Date().toISOString(),
    case_count: cases.length,
    passed: cases.length,
    failed: 0,
    clean_up_verified: cleanup,
    cases,
    limitations: [
      'No Vercel account, Daemon, Vercel Functions, managed queue, container or API was used.',
      'Synthetic queue state is an isolated JSON file and effects are fake records; no external side effects.',
      'Real local child processes received SIGTERM, but signal timing/rollout/delivery semantics of Vercel were not measured.',
      'No performance/cost/availability benchmark; 4 illustrative cases are not a statistical reliability study.'
    ]
  };
  console.log(JSON.stringify(receipt, null, 2));
}

if (process.argv[2] === '--worker') {
  worker(process.argv[3], process.argv[4]);
} else if (process.argv.length === 2) {
  experiment().catch((error) => {
    console.error(`SYNTHETIC_L1_FAIL: ${error.message}`);
    process.exitCode = 1;
  });
} else {
  throw Error('Usage: node queue-interruption-l1.mjs');
}
```


</details>
