Tarefas agendadas em Node.js parecem simples até você precisar colocar em produção. Crontab do servidor some no redeploy, processos morrem sem aviso e não existe log centralizado. Se o seu projeto já usa Docker, dá pra resolver isso com node-cron dentro do próprio container e deixar a plataforma cuidar do resto.
Este tutorial mostra como criar um worker de cron jobs em Node.js, empacotar em Docker e publicar na Guara Cloud. O resultado é um serviço que roda suas tarefas no horário certo, com logs visíveis e reinício automático se algo falhar.
Resposta rápida
Para rodar cron jobs em Node.js com Docker em produção, instale a biblioteca node-cron, crie um processo separado que executa as tarefas agendadas, empacote tudo em um Dockerfile e publique como um serviço na Guara Cloud. A plataforma mantém o container rodando 24/7, reinicia automaticamente em caso de falha e centraliza os logs. Você não precisa de crontab do sistema operacional nem de ferramentas externas como Bull ou Agenda para tarefas simples.
Principais pontos
- Use
node-cronpara cron jobs simples com sintaxe familiar do crontab. Para filas com retry e dead-letter, considere BullMQ. - Rode o worker como um serviço separado da API. Misturar os dois no mesmo container complica o debugging.
- Configure timezone explicitamente no node-cron. O default é UTC, e isso pega muita gente de surpresa.
- Log cada execução com timestamp e status. Sem isso, descobrir por que um job falhou às 3h da manhã vira um pesadelo.
- Não esqueça do healthcheck no Docker. Se o processo travar, a plataforma precisa saber.
Quando se aplica
Essa abordagem funciona bem para tarefas com execução periódica que não dependem de filas: limpeza de registros expirados, envio de relatórios diários, sincronização com APIs externas, geração de snapshots, checagem de saúde de integrações. Basicamente qualquer coisa que você faria com uma crontab no servidor, mas quer manter dentro da aplicação.
Quando não usar
Se os seus jobs precisam de retry automático, dead-letter queue, prioridade ou distribuição entre múltiplos workers, node-cron sozinho não resolve. Nesse caso, BullMQ com Redis é uma escolha melhor. Outro cenário: se o job é muito pesado (processamento de vídeo, ETL grande) e precisa escalar horizontalmente, um worker de fila dedicado faz mais sentido.
Antes de começar
- Node.js 20 ou superior instalado localmente
- Docker instalado e funcionando
- Conta na Guara Cloud (ou outra plataforma com suporte a containers)
- Um repositório Git com o código do projeto
1. Instale o node-cron e crie o worker
Comece instalando a dependência:
npm install node-cron
Agora crie o arquivo src/worker.js com a estrutura básica:
import cron from 'node-cron';
console.log('[worker] Cron jobs iniciados. Aguardando execuções...');
// Roda todo dia às 8h horário de Brasília
cron.schedule('0 8 * * *', async () => {
const start = Date.now();
try {
// Sua lógica aqui
console.log(`[job:relatorio-diario] iniciado às ${new Date().toISOString()}`);
await gerarRelatorioDiario();
console.log(`[job:relatorio-diario] concluído em ${Date.now() - start}ms`);
} catch (err) {
console.error(`[job:relatorio-diario] falhou: ${err.message}`);
}
}, {
timezone: 'America/Sao_Paulo'
});
// Roda a cada 6 horas
cron.schedule('0 */6 * * *', async () => {
try {
await limparTokensExpirados();
} catch (err) {
console.error(`[job:limpar-tokens] falhou: ${err.message}`);
}
}, {
timezone: 'America/Sao_Paulo'
});
// Mantém o processo vivo
process.on('SIGTERM', () => {
console.log('[worker] Recebido SIGTERM, encerrando...');
process.exit(0);
});
async function gerarRelatorioDiario() {
// Implementação real aqui
}
async function limparTokensExpirados() {
// Implementação real aqui
}
Preste atenção no timezone: 'America/Sao_Paulo'. Esquece isso e o cron roda em UTC. O “relatório das 8h” dispara às 5h da manhã e ninguém percebe até alguém olhar o banco.
2. Separe o worker da API no package.json
Adicione dois scripts diferentes:
{
"scripts": {
"start": "node src/server.js",
"worker": "node src/worker.js"
}
}
Por que separar? Porque a API responde a requisições HTTP e o worker é um processo de longa duração que nunca responde nada na porta. Colocar os dois no mesmo container significa que se o cron job travar o event loop, a API fica lenta junto. Manter serviços diferentes evita esse acoplamento.
3. Crie o Dockerfile para o worker
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
CMD ["node", "src/worker.js"]
Note o --omit=dev na instalação. O worker em produção não precisa de jest, eslint ou typescript. Isso reduz o tamanho da imagem e o tempo de deploy.
Se o seu projeto usa TypeScript, o Dockerfile precisa compilar antes:
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist/ ./dist/
CMD ["node", "dist/worker.js"]
4. Adicione um healthcheck
Sem healthcheck, a plataforma não sabe se o worker travou. Um worker de cron job não expõe porta HTTP, então o truque é usar um arquivo de “última verificação”:
import { writeFileSync } from 'fs';
import cron from 'node-cron';
// Heartbeat a cada 30 segundos
cron.schedule('*/30 * * * * *', () => {
writeFileSync('/tmp/healthy', Date.now().toString());
});
E no Dockerfile:
HEALTHCHECK --interval=60s --timeout=5s --retries=3 \
CMD test -f /tmp/healthy && \
node -e "const t=parseInt(require('fs').readFileSync('/tmp/healthy','utf8'));process.exit(Date.now()-t>120000?1:0)"
Isso garante que se o processo travar e parar de escrever o heartbeat, o Docker marca como unhealthy e a plataforma reinicia o container.
Variáveis de ambiente do worker
| Nome | Valor |
|---|---|
NODE_ENV | production |
TZ | America/Sao_Paulo |
DATABASE_URL | postgres://... |
LOG_LEVEL | info |
5. Publique na Guara Cloud
Com o Dockerfile pronto, o deploy é direto:
Passo a passo
- Crie um novo serviço na Guara Cloud
- Escolha deploy via GitHub (apontando para o repositório) ou imagem Docker
- No comando de execução, use npm run worker (ou acesse o container diretamente)
- Configure as variáveis de ambiente (DATABASE_URL, TZ, etc)
- Inicie o deploy e confirme nos logs que o worker iniciou
guara deploy --name meu-worker --dockerfile Dockerfile.worker --env TZ=America/Sao_Paulo Depois do primeiro deploy, verifique nos logs se aparece [worker] Cron jobs iniciados. Se aparecer, o serviço está rodando e vai executar os jobs nos horários configurados.
6. Monitore as execuções
Sem monitoramento, você só descobre que um job falhou quando alguém reclama. Esse feedback loop não é ideal.
Comece com logs estruturados. Use pino ou winston em vez de console.log. Com JSON estruturado, dá pra buscar nos logs da Guara Cloud por job name, status e duração. Depois adicione alertas de falha: se o job der erro, manda uma notificação para Discord, Slack ou email. Algo assim:
async function executarJob(nome, fn) {
const start = Date.now();
try {
await fn();
logger.info({ job: nome, duracao: Date.now() - start, status: 'ok' });
} catch (err) {
logger.error({ job: nome, erro: err.message, status: 'falha' });
await notificarFalha(nome, err);
}
}
Se você tem vários jobs, vale a pena registrar a última execução no banco e mostrar num endpoint interno. Qualquer pessoa do time pode checar se os jobs estão rodando sem precisar acessar logs.
Solução de problemas
- Problema O job roda no horário errado (3h em vez de 8h)
- Solução Adicione timezone: "America/Sao_Paulo" na opção do cron.schedule. Sem isso, ele usa UTC.
- Problema O worker para de rodar depois de algumas horas
- Solução Verifique se existe um tratamento de erro adequado. Uma promise rejeitada não catchada mata o processo Node. Adicione process.on("unhandledRejection").
- Problema O container reinicia mas os logs não mostram nada
- Solução O Dockerfile pode estar rodando o comando errado. Verifique se o CMD aponta para o arquivo do worker e não da API.
- Problema Dois jobs rodam ao mesmo tempo e conflitam no banco
- Solução Use um lock distribuído (Advisory Locks no Postgres) ou coloque os jobs em sequência usando async/await encadeado.
- Problema O job demora mais que o intervalo e acumula execuções
- Solução Adicione uma flag "executando" que impede sobreposição. Só inicia o próximo quando o anterior terminar.
Alternativas ao node-cron
Para contextos específicos, outras ferramentas fazem mais sentido:
BullMQ + Redis: quando precisa de retry, delay, prioridade ou dead-letter queue. Ideal para processamento assíncrono com garantia de entrega.
Agenda + MongoDB: semelhante ao BullMQ mas usa Mongo como backend. Bom se o projeto já tem MongoDB e não quer introduzir Redis.
Cron externo (GitHub Actions, cron-job.org): para tarefas muito simples que não precisam de contexto da aplicação. Exemplo: ping de healthcheck a cada 5 minutos.
A escolha entre eles depende do que você precisa garantir. Se é só “roda às 8h todo dia e loga se falhar”, node-cron resolve. Se precisa de retry e fila, vai de BullMQ.
Posso rodar cron jobs no mesmo container da API?
Pode, mas não deveria. Se o job travar o event loop ou consumir muita memória, a API vai sofrer junto. Serviços separados permitem escalar e debugar de forma independente.
O que acontece se o container reiniciar no meio de um job?
O job é interrompido e não retoma de onde parou. Se a tarefa precisa ser idempotente (pode rodar duas vezes sem problema), tudo bem. Senão, use um lock no banco para marcar "em execução" e verificar antes de começar.
node-cron aguenta quantos jobs simultâneos?
Não existe limite hardcoded. O gargalo é o que cada job faz. Se os 10 jobs são queries rápidas no banco, roda tranquilo. Se cada job faz chamadas HTTP pesadas, considere distribuir em workers separados.
Como testo cron jobs localmente sem esperar o horário?
Exporte a lógica do job em funções separadas e teste as funções diretamente. Para testar o agendamento, use intervalos curtos (a cada 10 segundos) durante desenvolvimento.
Publique seus cron jobs na Guara Cloud
Worker rodando 24/7 com reinício automático, logs centralizados e cobrança em Real. Sem gerenciar servidor.