# Can Vercel Daemons Replace a VPS Background Worker? Limits, SIGTERM, and Configuration

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

A VPS running a queue worker often spends much of its time polling. If the worker gets jobs from an external queue and writes results to a database, keeping an entire server around can feel unnecessary. Vercel's newly announced Daemons offer an appealing alternative: deploy a container, set an instance count, and let the platform keep it running.

But that doesn't mean you can retire the VPS. After comparing Vercel's [backend announcement](https://vercel.com/blog/backends-on-vercel-python-containers-and-daemons), [Daemon limits](https://vercel.com/docs/daemons/limits), and [management guide](https://vercel.com/docs/daemons/managing-daemons), my dividing line is this: **a pull-based worker with external state and safe retry behavior is worth evaluating; a service needing inbound ports, persistent local files, or a full CI execution environment is not a drop-in migration.**

On October 15, 2026, Vercel announced Python backend GA, containerized Functions, and Daemons Public Beta. This analysis is based on the official docs and project JSON Schema, with a separate local Node.js \`SIGTERM\` experiment to expose the consequences of acknowledging queue work before or after its effects. Documentation establishes the provider's contracts; the local test illustrates the acknowledgement trade-off.

## Serving requests and pulling work are different jobs

A FastAPI, Flask, or Django HTTP API fits Python on Vercel, now GA, using request-driven Functions. A custom OCI image that serves HTTP can use Container Functions (Beta), which scale with incoming traffic. Daemons are different: persistent, fixed-count processes that pull work from queues or databases. The [Daemon overview](https://vercel.com/docs/daemons) says they cannot receive inbound HTTP or TCP traffic.

| Existing workload | Candidate runtime | Migration question |
| --- | --- | --- |
| FastAPI or Flask public API | Python Functions (GA) | Request duration, dependencies, outbound connections |
| Custom OCI service receiving HTTP | Container Functions (Beta) | Request-triggered scaling and Function pricing |
| Queue consumer writing to an external database | Daemons (Public Beta) | Fixed instances, external state, retry and idempotency |
| Service relying on durable local files or cache | VPS or a host with persistent volumes | Daemons currently have no persistent local storage |
| Inbound TCP listener | VPS or another appropriate TCP service | Daemons expose no inbound port |

GitHub self-hosted runners deserve extra scrutiny. A runner may also poll for jobs, but those jobs can require container builds, workspaces, caching, and sensitive deployment permissions. **Similar polling behavior doesn't make filesystem access, job isolation, or privilege requirements compatible.** Audit the actual workflows rather than assuming that support for a generic background worker means an existing CI runner can migrate unchanged.

## Ten vCPU-hours is not an always-free worker

The [Daemons Beta limits](https://vercel.com/docs/daemons/limits) give a Hobby team **10 vCPU-hours per month**. A continuously running 1-vCPU instance therefore exhausts that allocation in about ten hours. At the limit, the team's Daemons stop until the next month or a plan upgrade.

The unit is *allocated vCPU count × hours of runtime*, not a measurement of how busy the process was. Even a worker mostly waiting for messages isn't a free 24/7 machine. And although Vercel describes Active CPU billing for request-driven Container Functions, it would be a mistake to assume the same waiting-time treatment applies to Daemons without checking their own billing contract.

For Pro, **16 vCPUs and 32 GiB of RAM** are the default concurrent resource limits, not a quoted monthly dollar price. A fair VPS comparison needs the actual occupied resources, continuous runtime, network/queue charges, and maintenance burden. A single plan sticker price doesn't answer that question.

The current Beta also lacks Preview Deployments, autoscaling, persistent local disks, and running Daemons directly through \`vercel dev\`. Production deployments start them; for local development you run the container yourself. Those limits can matter more than application code, especially if your current workflow depends on preview environments or disk-backed caches. The [limits page](https://vercel.com/docs/daemons/limits) also documents restrictions around projects configured with a nested root directory.

## SIGTERM isn't the real problem; acknowledging the job is

During a deployment, [Vercel documents](https://vercel.com/docs/daemons/managing-daemons) that old and new Daemon versions may briefly overlap. An old worker receives \`SIGTERM\` and has a default ten-second grace period. The question isn't just whether the process exits gracefully. It's what happens to a job that was half-finished.

Suppose a consumer commits an order update, then gets terminated before acknowledging the queue message. A later delivery can repeat the business side effect. Reverse the sequence—acknowledge first, then commit—and termination can leave a message marked done without the actual work.

I tested those two ordering failures in a small, **self-authored offline Node.js v22.16.0 / Linux x64 model**. It stores one synthetic job and fake effects in a JSON file, forks real local Node subprocesses, and sends \`SIGTERM\` during three interruption paths. It tests acknowledgement order, not Vercel latency or throughput.

| Offline synthetic case | Processing attempts | Recorded effects | Acknowledged? |
| --- | ---: | ---: | --- |
| Commit effect, then acknowledge | 1 | 1 | Yes |
| Interrupt after effect; replay with job-ID dedupe | 2 | 1 | Yes |
| Interrupt after effect; replay **without** dedupe | 2 | **2** | Yes |
| Acknowledge **before** effect, then interrupt | 1 | **0** | Yes |

All four subprocess scenarios produced their expected observations. The negative controls are the useful part: **without deduplication, a replay duplicates the effect; premature acknowledgement can lose the work altogether.**

The core check is deliberately small; \`effects\` below is a record in our synthetic queue model:

```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';
}
```

With \`jobId\` deduplication the second attempt notices that the effect already exists. Remove that condition and it appends a duplicate. In the early-ack case, the recovered worker sees \`acked: true\` and skips the job despite the missing effect.

That changes what I'd validate before migrating any real queue consumer. What does the actual queue do when a process exits? Will the message be redelivered? Can a repeated update create an externally visible side effect? This local model doesn't implement a production queue's visibility timeout, transactions, or delivery protocol. Use business keys or idempotent writes, then test the specific queue's retry semantics.

## The configuration is simple; the contract matters

The [Daemon configuration reference](https://vercel.com/docs/daemons/config-reference) describes a single-region worker configuration like this. The \`worker/\` directory still needs a Dockerfile and a program that consumes real work:

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

\`instances\` is the fixed count per region; \`iad1\` is Vercel's Washington-region identifier. This setting doesn't turn on autoscaling.

There is an easy-to-miss compatibility detail in the [project JSON Schema](https://openapi.vercel.sh/vercel.json): the older \`replicas\` property remains accepted but is deprecated, and \`instances\` takes precedence when both are present. Both current management and configuration docs use \`instances\`, so that's what new projects should choose.

The documented \`SIGTERM\` grace defaults to ten seconds. The schema also lists \`terminationGracePeriodSeconds\`, but a longer grace window won't repair duplicate effects or an incorrect ack order. Make the work restart-safe first; verify the grace setting during a real deployment.

For lightweight consumers that pull from an external queue, keep all durable state outside the container, and tolerate a fixed instance count, Daemons deserve a trial on noncritical work. I'd keep the VPS for services needing persistent workspaces, complicated CI privileges, inbound connections, or a dependable preview environment. Moving a production runner onto another platform doesn't remove the security boundary around untrusted jobs and deployment secrets.

For adjacent decisions, see my [ChatGPT Web + GitHub self-hosted runner setup](/posts/chatgpt-web-github-self-hosted-runner/) or the [PanelAlpha Engine VPS hosting-control-plane test](/posts/panelalpha-engine-vps-hosting-control-plane/).

To reproduce the four offline scenarios, save the complete script below as \`queue-interruption-l1.mjs\` and run \`node queue-interruption-l1.mjs\` using Node.js 20 or newer. The code prints its own JSON results.

<details>
<summary>Full reproducible Node.js synthetic worker test</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>
