Como rodar um bot, webhook ou cron job em Node.js 24/7 sem um VPS

Webhook, cron job e worker de fila precisam de um processo que nunca dorme. Colocamos os três em um container, com o estado em um backend gerenciado, e medimos o que custa ficar acordado, incluindo a hora que o plano Free nos deu.

Uma linha do tempo de heartbeats medida em 15 e 16 de setembro de 2026: um heartbeat por minuto durante 60 minutos no plano Free, depois o container é destruído e a linha fica reta; no plano Shared, na manhã seguinte, vem o heartbeat 61 e a linha nunca mais para

Tradução do original em inglês, testado em 16 de setembro de 2026. Código, números e datas são os do original. Read in English

Você tem um script que precisa ficar acordado o tempo todo. Um bot que espera mensagens, um webhook que precisa responder em menos de dez segundos, um job que roda a cada minuto. E toda página de hospedagem oferece uma de duas opções que não servem: uma função que morre entre requisições, ou uma máquina Linux que agora você precisa manter atualizada.

Esta é a distinção que faz o resto do post fazer sentido. Um request handler roda quando alguém o chama. Um processo sempre ativo roda porque existe — timers, loops e sockets abertos precisam disso. O segundo tipo é o motivo de as pessoas alugarem um VPS, e é o tipo que uma plataforma de containers pode rodar para você, se ela mantiver o container vivo.

Os outros três posts sobre containers tratam de apps dirigidos por requisição (um app web em Node, um serviço FastAPI, uma página Next.js com SSR).

Este post trata do processo que precisa continuar de pé entre as requisições.

Escrevemos um processo Node de 75 linhas que faz os três trabalhos — um receptor de webhook assinado no estilo do GitHub, um tick de cron a cada 60 segundos, um worker de fila a cada 5 segundos — com todo o estado em um backend gerenciado.

Fizemos o deploy, medimos, vimos o plano Free matá-lo depois de exatamente uma hora e o movemos para um plano de US$ 5/mês, onde ele escreve um heartbeat por minuto desde então. Tudo abaixo vem dessa execução, em 15 e 16 de setembro de 2026.

Pegue o código funcionando: o repositório companion está em github.com/templates-back4app/always-on-worker — server.js, Dockerfile, cloud/main.js e deploy-check.sh, que dispara um webhook assinado e um sem assinatura contra um deployment e confere as duas respostas.

O que “sempre ativo” exige de verdade?

Três coisas, e a maioria das páginas de hospedagem só menciona a primeira: um processo que não é parado entre requisições, uma URL HTTPS pública para os webhooks baterem e um lugar para o estado viver que não seja o processo. Pule a terceira e cada restart apaga a sua fila.

O que o trabalho precisaPor que um request handler não dá contaOnde fica neste post
Um timer que dispara a cada minutoNada chama um handler às 03:00Container, tick de cron
Um loop que consulta uma fila a cada 5 sHandlers não têm “entre requisições”Container, loop do worker
Uma URL que responde webhooks em segundosCold starts consomem o timeout de entregaContainer, /webhook
Estado que sobrevive ao processoO container é descartávelBackend, três classes
Um jeito de saber que ele ficou mesmo no ar“Deve estar rodando” não é métricaBackend, contagem de heartbeats

A resposta clássica é um VPS: uma máquina de US$ 5, cron, pm2, e agora você é a pessoa que aplica patch no OpenSSL. A resposta serverless cobre o webhook e, com um scheduler, o timer — mas não o loop, nem o socket aberto que um bot mantém. Um container que a plataforma mantém vivo cobre as cinco linhas sem os patches.

A Back4app é a que testamos porque as duas metades vêm de uma só conta: um app de backend com banco de dados gerenciado e hooks, e um app de container que roda o Dockerfile e, em um plano pago, o mantém rodando. O resto deste post é o passo a passo medido pelos dois.

Um processo que precisa estar acordado entre eventos é o motivo inteiro de as pessoas alugarem servidores. Essa é a única parte que vale a pena pagar.

O que roda dentro do container sempre ativo?

Um processo Express com três trabalhos que não compartilham nada além do backend. POST /webhook verifica uma assinatura HMAC no estilo do GitHub e guarda o evento. Um setInterval escreve um Heartbeat a cada 60 segundos — conte-os depois e você tem o uptime real. Um segundo intervalo pega o Job pendente mais antigo a cada 5 segundos e o conclui.

Arquitetura: o GitHub envia webhooks assinados ao container; dentro dele um tick de cron e um loop de fila rodam em timers; os três escrevem objetos Event, Heartbeat e Job no backend Back4app

O Dockerfile inteiro tem oito linhas. A plataforma lê a porta do EXPOSE:

# Stack: Docker | Node.js 22 (alpine) | File: Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "server.js"]

A rota de webhook é a parte que as pessoas erram. Ela precisa do body bruto para a assinatura, de uma comparação em tempo constante e de uma resposta rápida — guarde primeiro, trabalhe depois:

// Stack: Node.js 22.x | Express 5.x + Parse JS SDK 8.x | File: server.js (webhook)
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

app.post("/webhook", async (req, res) => {
  const sig = req.get("x-hub-signature-256") ?? "";
  const expected = "sha256=" + createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody).digest("hex");
  if (!WEBHOOK_SECRET || sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).json({ error: "bad signature" });
  }
  const ev = new Parse.Object("Event");
  ev.set("source", req.get("x-github-event") ?? "unknown");
  ev.set("payload", req.body);
  await ev.save();
  res.status(202).json({ stored: ev.id });
});

O cron e o worker são dois timers. Nenhum dos dois guarda em memória nada que importe:

// Stack: Node.js 22.x | Express 5.x + Parse JS SDK 8.x | File: server.js (cron + queue)
setInterval(async () => {                       // 1. cron: a heartbeat every minute
  const hb = new Parse.Object("Heartbeat");
  hb.set("at", new Date()); hb.set("up_s", Math.round((Date.now() - startedAt) / 1000));
  await hb.save();
}, 60_000);

setInterval(async () => {                       // 2. queue: oldest pending Job, every 5 s
  const job = await new Parse.Query("Job").equalTo("status", "pending").ascending("createdAt").first();
  if (!job) return;
  job.set("status", "running"); await job.save();
  const t0 = Date.now();
  const result = await runJob(job.get("kind"), job.get("input"));
  job.set("status", "done"); job.set("result", result); job.set("ms", Date.now() - t0);
  await job.save();
}, 5_000);
Medido · always-on-worker · setembro de 2026
75linhas no container
→
9linhas de Cloud Code
0servidores, daemons de cron ou serviços de fila

Você precisa de uma conta gratuita na Back4app, uma conta no GitHub, curl e openssl. Nada de Docker na sua máquina: a plataforma faz o build da imagem, e este post foi produzido em um Mac sem Docker instalado.

Para onde vai o estado?

Para um app de backend — três classes que o container cria na primeira escrita, e uma regra que o container não consegue burlar. Dashboard → New App → Build your Backend, dê um nome, Create; a página Overview mostra o App ID e, no dropdown Keys, a JavaScript key. Esses dois valores são a única configuração de que o container precisa.

A JavaScript key que o container envia é uma chave de cliente: o que ela pode fazer é decidido pelas permissões da classe, que medimos trava por trava em outro post.

A única regra vive no Cloud Code, dentro do backend. Qualquer coisa pode criar um Job — um curl, outro serviço, o AI Agent — e o hook decide o que é um job válido. Um job com um kind que o worker não conhece nunca chega à fila:

// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
Parse.Cloud.beforeSave("Job", (request) => {
  const job = request.object;
  if (job.isNew()) {
    if (!["fetch-title", "sleep"].includes(job.get("kind")))
      throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "unknown job kind.");
    job.set("status", "pending");
  }
});

Cole em Cloud Code → cloud/main.js na dashboard do backend e clique em Deploy. Depois prove com uma requisição — enviamos {"kind":"rm-rf"} e recebemos 400 {"code":142,"error":"unknown job kind."}, e {"kind":"sleep","input":{"ms":1500}} voltou 201 com status já definido como pending pelo hook.

Aqui está um job criado de dois jeitos. O worker nunca se importa com qual deles o criou:

// Stack: Node.js 22.x | Parse JS SDK 8.x | File: any Node process with the App ID and JS key
const job = new Parse.Object("Job");
job.set("kind", "fetch-title");
job.set("input", { url: "https://www.back4app.com/" });
await job.save();               // the hook sets status = "pending"; the worker picks it up within 5 s

Mesmo objeto, mesmo hook. A forma REST é o que o SDK envia por baixo dos panos.

Como fazer o deploy de um worker que não tem página web?

Envie o repositório para o GitHub e, no lado de Containers, New App → Deploy from GitHub. Na primeira vez, o GitHub pede para instalar o app Back4app Containers nos repositórios que você escolher; a lista de repositórios na dashboard atualiza no momento em que você termina.

A tela Deploy a web app listando o repositório always-on-worker entre os repositórios concedidos ao GitHub App, cada um com um botão Select

Configure. Selecione o repositório; o formulário detectou o Dockerfile e ofereceu um buildpack Node.js como alternativa. Deixamos a porta em auto (ele lê EXPOSE 8080), adicionamos três variáveis de ambiente — PARSE_APP_ID, PARSE_JS_KEY, WEBHOOK_SECRET — e mudamos o Health check de / para /healthz.

O formulário de deploy com três variáveis de ambiente, PARSE_APP_ID, PARSE_JS_KEY e WEBHOOK_SECRET, com os valores mascarados, e o caminho do health check definido como /healthz

Deploy. Clicamos às 14:55:08. O botão ficou em “Deploying…” por 20 segundos até a página do app aparecer, e então o log correu PREPARING → FETCHING GITHUB REPOSITORY → BUILDING IMAGE → LAUNCHING CONTAINER → CHECKING HEALTH → DEPLOYMENT READY às 14:56:05. Às 14:56:11, /healthz já respondia com o número da versão.

O Overview do container depois do primeiro deploy: status Available, o deployment Ready em 36 s, o log terminando em Pushed image e um card Temporary URL Active no plano Free

Medido · primeiro deploy · 15 de setembro de 2026
57 sclique em Deploy → DEPLOYMENT READY
→
24 sdisso, build da imagem
25 MBde RAM ocioso, de 256

Como medimos: um cronômetro do clique em Deploy até DEPLOYMENT READY, plano Free, um deploy em 15 de setembro de 2026. O tempo de push até o ar é um push com autodeploy ligado, no plano Shared, no dia seguinte, observado por um loop consultando /healthz em busca da nova string de versão.

O deploy-check.sh do repositório faz o que você faria à mão: um POST /webhook assinado (202 {"stored":"…"}), o mesmo body sem assinatura (401), depois /stats. Um Job criado pela REST API às 14:56:38.6 foi pego e concluído às 14:56:41.4 — o loop de 5 segundos funcionou no primeiro tick.

# Stack: bash | File: deploy-check.sh (the signed call)
BODY='{"action":"opened","number":1}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')"
curl -X POST -H "Content-Type: application/json" -H "X-GitHub-Event: pull_request" \
     -H "X-Hub-Signature-256: $SIG" -d "$BODY" "https://alwaysonworker-<id>.b4a.run/webhook"
# {"stored":"…"}  ← HTTP 202

Como os heartbeats e os jobs aparecem no banco de dados?

Três telas na dashboard do backend, e toda linha nelas foi escrita pelo container acima — sem dados de seed, sem schema definido à mão. Esta é a metade de “sem servidores para administrar” que o container nunca mostra para você.

Overview — de onde vêm as duas variáveis. Dashboard → New App → Build your Backend → Create. A página Overview mostra o App ID e, no dropdown Keys, a JavaScript key que o worker usa. Esses são PARSE_APP_ID e PARSE_JS_KEY no formulário do container, e nada mais sobre o banco de dados existe do lado do container.

O Overview do backend always-on-worker: App ID, o dropdown Keys em JavaScript Key (valor borrado), Parse Server 7.5.2 e MongoDB 3.6 à direita

Cloud Code — onde a regra vive. cloud/main.js aberto no editor, com o hook beforeSave("Job") da seção anterior. O badge no topo, Files pending deploy (1), é o que merece atenção: em um backend novo ele fica em (2) depois do primeiro Deploy, e é assim que você sabe que nada foi enviado.

O editor de Cloud Code com cloud/main.js aberto mostrando o hook beforeSave de Job, e o badge Files pending deploy (1) ao lado do botão Deploy

Database Browser — a classe Heartbeat, ordenada por createdAt. O container criou essa classe com o primeiro save; as colunas at e up_s foram inferidas do JSON. As linhas 60 e 61 ficam lado a lado: up_s 3600 em 15 de setembro, depois up_s 60 em 16 de setembro — o intervalo de 20 horas em que o container gratuito não existiu.

O Database Browser mostrando a classe Heartbeat com 1.59k objetos, ordenada por createdAt: linhas de 15 de setembro com up_s até 3600, depois linhas de 16 de setembro começando de novo em 60

Database Browser — a classe Job. Dois jobs, ambos done: o job fetch-title que a chamada REST criou (ms 97) e um job sleep (ms 1503). O worker escreveu status, result e ms; o hook escreveu o pending inicial; quem chamou escreveu só kind e input.

O Database Browser mostrando a classe Job com dois objetos, os valores de ms 1503 e 97, o JSON de input e o status done

Por quanto tempo o plano Free mantém o worker rodando?

Sessenta minutos, contados do momento em que o deploy começa — e então o container é destruído, não pausado. Este é o achado que muda o formato do post, então aqui está exatamente o que vimos em 15 de setembro de 2026.

O Overview mostrou um card no instante em que o app subiu: Temporary URL Active — URL is temporary and will be live for 60 minutes. Tínhamos um poller em /healthz e na contagem de heartbeats do backend, uma vez por minuto.

O deploy começou às 14:55:28. Às 15:55:27 a URL respondeu 404 not found. O backend tinha exatamente 60 heartbeats e nunca recebeu o 61º — o timer dentro do container tinha parado, porque o container tinha parado.

O Overview de um app gratuito depois da hora: status URL Expired, um card Temporary URL Expired oferecendo Upgrade for a Permanent URL ou Redeploy App, e o último deployment marcado como Destroyed

Em um segundo app pegamos as linhas do log: The Back4app custom domain has expired for free plan → COOLING DOWN… → FINISHING CONTAINER… → DEPLOYMENT DESTROYED, com um minuto de intervalo entre elas.

O card da dashboard então diz Your App temporary URL has expired. Please upgrade to a paid plan to continue developing, or redeploy your app to finish your product experimentation. Um redeploy reinicia a hora.

Quanto custa o “24/7”, e o que muda?

Cinco dólares por mês pelo menor plano pago, e a troca de plano sozinha refez o deploy do app na mesma URL. Plan → Upgrade abre a tabela; Shared 0,5 vCPU / 512 MB / 100 GB de transferência custa US$ 5/mês, Shared 1 vCPU / 1 GB custa US$ 15, e os planos dedicados começam em US$ 50 (tudo em 16 de setembro de 2026 — confira a página de preços em vez deste post).

A tabela de planos: Free, depois planos Shared a US$ 5, US$ 15 e US$ 25 por mês e planos Dedicated a US$ 50, US$ 100 e US$ 200, cada um com CPU, RAM e transferência

Trocamos o plano na manhã seguinte. Sem clicar em Deploy, o log mostrou PREPARING DEPLOYMENT às 12:35:37 e DEPLOYMENT READY às 12:36:41 — 63 segundos — na mesma URL b4a.run; o card Temporary URL tinha sumido. Às 12:37:37 o container escreveu o heartbeat #61. O número 60 era da tarde anterior, e nada no meio se perdeu, porque nada estava no container.

O Overview do container no plano Shared: US$ 5,00 por mês, 0,5 vCPU e 512 MB, status Available, o redeploy automático Ready em 1m 3s

A lista de aplicações web depois da troca de plano: quatro apps, cada um marcado SHARED e Available, e o contador Free web applications 0 / 50

O plano pago também libera o Autodeploy (Settings → Build & deploy). Ligamos, salvamos e fizemos o push de um bump de versão de uma linha às 12:45:02. O webhook do GitHub chegou à plataforma antes de o nosso comando git push retornar: PREPARING às 12:45:05.

DEPLOYMENT READY veio às 12:45:48, e o poller viu a nova versão 44 segundos depois do push. O container antigo continuou respondendo até a troca — zero polls com falha.

Settings → Build & deploy: método de build Dockerfile, branch main, diretório raiz ./, a chave Autodeploy ligada, porta deixada vazia para detecção automática

Medido · plano Shared · 16 de setembro de 2026
63 stroca de plano → redeploy, mesma URL
→
44 sgit push → nova versão no ar
US$ 5por mês, 0,5 vCPU · 512 MB

Por que o webhook devolve 401? Erros do worker e suas correções

Quebramos o worker de propósito, e a plataforma o quebrou uma vez por nós. Cada entrada começa com o que você vai ver de fato.

401 {"error":"bad signature"} em todo webhook. Três causas, na ordem em que esbarramos nelas: o secret no ambiente do container é diferente do que você usou para assinar; você assinou o JSON parseado em vez dos bytes brutos (ordem de chaves e espaços mudam o hash — capture req.rawBody no callback verify); ou o header é X-Hub-Signature (SHA-1) em vez de X-Hub-Signature-256. Teste com deploy-check.sh antes de ligar o remetente real.

400 {"code":142,"error":"unknown job kind."} ao criar um Job. O hook do backend rejeitou — funcionando como previsto. Adicione o kind à lista em cloud/main.js, refaça o deploy do Cloud Code e prove o redeploy com uma requisição; veja o achado acima sobre o primeiro deploy não enviar nada.

404 not found na URL, dashboard diz URL Expired. A hora gratuita acabou e o container se foi. Refaça o deploy para ganhar outra hora, ou troque o plano; a troca de plano refaz o deploy sozinha.

DEPLOYMENT READY em um container cujas rotas todas falham. A verificação da plataforma só precisa que a porta responda — um 404 passou no nosso teste —, então uma chave errada ou um handler que travou não é pego na hora do deploy. Rode deploy-check.sh contra a URL depois de cada deploy; esse é o health check.

Success on deploying your changes! mas o hook não faz nada. O primeiro deploy de Cloud Code em um backend novo não envia nenhum arquivo. Olhe Logs → System em busca de main.js not found, edite o arquivo no editor e faça o deploy de novo.

Os heartbeats param, mas /healthz continua respondendo. O save() do timer está falhando — uma chave revogada, um backend pausado, uma classe Heartbeat deixada somente leitura. A rota não toca o backend, então continua verde. Nossa rota /stats toca; consulte essa no seu monitor de uptime.

Quando um container sempre ativo é a resposta errada?

Pelo nosso próprio manifesto, um post em que só a plataforma ganha não é publicado — então aqui está a outra coluna.

  • Seu “bot” só reage a requisições. Um webhook que guarda e responde, sem timers e sem sockets abertos, é trabalho para uma função. Você paga por chamada e nada roda às 03:00.
  • Você precisa que o job rode exatamente às 03:00:00. setInterval deriva, e um redeploy o zera — o primeiro heartbeat do nosso worker depois da troca de plano veio 60 segundos depois de o processo iniciar, às 12:37:37, não no minuto cheio. Agendas de verdade pertencem a um scheduler que chama a sua rota, ou aos Cloud Jobs agendados do próprio backend.
  • A fila precisa de garantias de entrega. Nosso loop é um processo pegando a linha pendente mais antiga a cada 5 segundos; duas réplicas disputariam. Se você precisa de exactly-once, retries com backoff e dead letters, use um serviço de fila e mantenha este container como consumidor.
  • Você precisa de root, de uma GPU ou de mais de 8 GB. Então você está em território de VPS, e os patches vêm junto.

Se nenhum desses descreve você, um container pequeno que nunca dorme na frente de um backend que nunca esquece é o formato que escolheríamos de novo — a US$ 5/mês, o preço de um VPS pequeno, e ninguém precisa aplicar patch nele.

O que você acrescentaria ao worker em seguida?

Você tem um processo que fica de pé, um webhook que rejeita qualquer coisa sem assinatura, um cron que você audita contando linhas e uma fila que qualquer coisa pode alimentar. Daqui:

  • Aponte um remetente real para ele. GitHub → repositório → Settings → Webhooks → sua URL + o mesmo secret; o header X-Hub-Signature-256 é exatamente o que a rota verifica.
  • Adicione um kind de job. Um branch em runJob, uma entrada na lista do hook, um push — o autodeploy faz o resto em menos de um minuto.
  • Alerte sobre o silêncio. Um segundo processo, minúsculo (ou os jobs agendados do backend), que verifica se o Heartbeat mais recente tem mais de dois minutos é o sistema de monitoramento inteiro.

Todo o código acima está completo e testado — o repositório companion inclui deploy-check.sh, que roda as verificações de health, assinada, sem assinatura e stats contra qualquer URL de deployment que você passar.

Tagscontainerscronwebhookqueuenodedockerparse-server

Perguntas frequentes

Dá para rodar um cron job ou um bot 24/7 sem um VPS?

Sim, se a hospedagem mantiver um container rodando o tempo todo em vez de acordá-lo a cada requisição. Rodamos um processo Node com um timer de 60 segundos, uma rota de webhook e um loop de fila de 5 segundos no Back4app Containers; no plano Shared (US$ 5/mês, 0,5 vCPU, 512 MB em 16 de setembro de 2026) ele ficou no ar e escreveu um heartbeat por minuto. No plano Free o processo foi destruído depois de 60 minutos.

Por que não uma função serverless para o webhook e o cron?

Uma função dá conta do webhook, e um scheduler pode chamá-la a cada minuto. O que uma função não consegue é manter um loop em memória: o worker de fila que consulta a cada 5 segundos, um bot que mantém um websocket aberto, qualquer coisa que precise estar acordada entre eventos. Esse é o caso para um processo sempre ativo.

Onde fica o estado se o container é descartável?

Em um app de backend da Back4app: cada evento de webhook, cada heartbeat e cada job é uma linha em um banco de dados gerenciado, escrita por HTTPS com o Parse SDK. Quando o container gratuito foi destruído e depois substituído, o heartbeat número 61 veio depois do número 60 — os dados sobreviveram porque nada deles estava no container.

Como verifico a assinatura de um webhook do GitHub em Node?

Calcule o HMAC-SHA256 do body bruto da requisição com o seu secret de webhook, prefixe com sha256= e compare com o header X-Hub-Signature-256 usando crypto.timingSafeEqual. Você precisa do body bruto, não do JSON parseado — capture-o no callback verify do express.json. Uma requisição sem assinatura precisa receber 401 antes de qualquer coisa ser guardada.

Leitura complementar

Revisado e verificado por Back4app Engineering, Editorial review na Back4app

Testado em 16 de setembro de 2026 — Containers: Back4app, plano Shared, 0,5 vCPU, 512 MB (plano Free na primeira hora) · Backend: Back4app, USA East · Local: Node 25, Express 5.

Mais da Back4app Engineering

Containers · tutorial

How to Deploy a Node.js App With Docker and a Database, Without Managing a Server [Full Repo]

Containers · tutorial

How to Deploy FastAPI With Docker and a Database, Without Managing a Server [Full Repo]

Backend · tutorial

Your Frontend Talks Straight to the Database. Here Is How to Lock It Down [Full Repo]