Can Vercel Daemons Replace a VPS Background Worker? Limits, SIGTERM, and Configuration
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, Daemon limits, and management guide, 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 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 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 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 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:
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 describes a single-region worker configuration like this. The `worker/` directory still needs a Dockerfile and a program that consumes real work:
{
"$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: 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 or the PanelAlpha Engine VPS hosting-control-plane test.
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.
Full reproducible Node.js synthetic worker test
/**
* 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');
}