Scheduled tasks in Node.js look straightforward until you need to ship them to production. Server crontabs disappear on redeploy, processes die without notice, and there is no centralized logging. If your project already uses Docker, you can solve this with node-cron running inside the container and let the platform handle the rest.
This tutorial shows how to build a cron job worker in Node.js, package it with Docker, and deploy it on Guara Cloud. The result is a service that runs your tasks on schedule, with visible logs and automatic restarts on failure.
Quick answer
To run cron jobs in Node.js with Docker in production, install node-cron, create a separate process that executes the scheduled tasks, package everything in a Dockerfile, and deploy it as a service on Guara Cloud. The platform keeps the container running 24/7, restarts it on failure, and centralizes the logs. You do not need OS-level crontab or external tools like Bull or Agenda for simple tasks.
Key takeaways
- Use
node-cronfor simple cron jobs with familiar crontab syntax. For queues with retry and dead-letter, consider BullMQ. - Run the worker as a separate service from your API. Mixing both in the same container makes debugging harder.
- Set timezone explicitly in node-cron. The default is UTC, and this catches a lot of people off guard.
- Log every execution with timestamp and status. Without this, figuring out why a job failed at 3 AM turns into a nightmare.
- Do not skip the Docker healthcheck. If the process hangs, the platform needs to know about it.
When this applies
This approach works well for periodic tasks that do not depend on queues: cleaning up expired records, sending daily reports, syncing with external APIs, generating snapshots, checking integration health. Basically anything you would do with a server crontab but want to keep inside the application.
When not to use
If your jobs need automatic retry, dead-letter queues, priority ordering, or distribution across multiple workers, node-cron alone will not cut it. BullMQ with Redis is a better choice for those cases. Another scenario: if the job is heavy (video processing, large ETL pipelines) and needs to scale horizontally, a dedicated queue worker makes more sense.
Before you start
- Node.js 20 or later installed locally
- Docker installed and working
- A Guara Cloud account (or any platform with container support)
- A Git repository with your project code
1. Install node-cron and create the worker
Start by installing the dependency:
npm install node-cron
Now create src/worker.js with the basic structure:
import cron from 'node-cron';
console.log('[worker] Cron jobs started. Waiting for executions...');
// Runs every day at 8 AM
cron.schedule('0 8 * * *', async () => {
const start = Date.now();
try {
console.log(`[job:daily-report] started at ${new Date().toISOString()}`);
await generateDailyReport();
console.log(`[job:daily-report] completed in ${Date.now() - start}ms`);
} catch (err) {
console.error(`[job:daily-report] failed: ${err.message}`);
}
}, {
timezone: 'America/Sao_Paulo'
});
// Runs every 6 hours
cron.schedule('0 */6 * * *', async () => {
try {
await cleanExpiredTokens();
} catch (err) {
console.error(`[job:clean-tokens] failed: ${err.message}`);
}
}, {
timezone: 'America/Sao_Paulo'
});
// Keeps the process alive and handles graceful shutdown
process.on('SIGTERM', () => {
console.log('[worker] Received SIGTERM, shutting down...');
process.exit(0);
});
async function generateDailyReport() {
// Your implementation here
}
async function cleanExpiredTokens() {
// Your implementation here
}
Pay attention to timezone: 'America/Sao_Paulo'. Forget this and the cron runs in UTC. Your “8 AM report” fires at 5 AM local time and nobody notices until someone checks the database.
2. Separate worker and API in package.json
Add two different scripts:
{
"scripts": {
"start": "node src/server.js",
"worker": "node src/worker.js"
}
}
Why separate them? The API responds to HTTP requests while the worker is a long-running process that never listens on a port. Putting both in the same container means that if a cron job blocks the event loop, the API slows down too. Keeping them as different services prevents this coupling.
3. Create the Dockerfile for the 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"]
Notice the --omit=dev flag. The production worker does not need jest, eslint, or typescript. This reduces image size and deploy time.
If your project uses TypeScript, the Dockerfile needs a build step:
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. Add a healthcheck
Without a healthcheck, the platform has no way to know if the worker is stuck. A cron worker does not expose an HTTP port, so the trick is to use a “last check” file:
import { writeFileSync } from 'fs';
import cron from 'node-cron';
// Heartbeat every 30 seconds
cron.schedule('*/30 * * * * *', () => {
writeFileSync('/tmp/healthy', Date.now().toString());
});
And in the 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)"
This ensures that if the process hangs and stops writing the heartbeat, Docker marks it as unhealthy and the platform restarts the container.
Worker environment variables
| Name | Value |
|---|---|
NODE_ENV | production |
TZ | America/Sao_Paulo |
DATABASE_URL | postgres://... |
LOG_LEVEL | info |
5. Deploy on Guara Cloud
With the Dockerfile ready, the deploy is straightforward:
Step by step
- Create a new service on Guara Cloud
- Choose deploy via GitHub (pointing to your repository) or Docker image
- Set the run command to npm run worker (or access the container directly)
- Configure environment variables (DATABASE_URL, TZ, etc)
- Start the deploy and confirm in the logs that the worker started
guara deploy --name my-worker --dockerfile Dockerfile.worker --env TZ=America/Sao_Paulo After the first deploy, check the logs for [worker] Cron jobs started. If you see it, the service is running and will execute the jobs at the configured times.
6. Monitor the executions
Without monitoring, you only find out a job failed when someone complains. That is not a great feedback loop.
Start with structured logs. Use pino or winston instead of console.log. With structured JSON you can search Guara Cloud logs by job name, status, and duration. Then add failure alerts: if a job errors out, push a notification to Discord, Slack, or email. Something like:
async function runJob(name, fn) {
const start = Date.now();
try {
await fn();
logger.info({ job: name, duration: Date.now() - start, status: 'ok' });
} catch (err) {
logger.error({ job: name, error: err.message, status: 'failed' });
await notifyFailure(name, err);
}
}
If you have multiple jobs, it is worth recording the last execution in the database and exposing it on an internal endpoint. That way, anyone on the team can check if jobs are running without needing to access logs.
Troubleshooting
- Problem The job runs at the wrong time (3 AM instead of 8 AM)
- Solution Add timezone: "America/Sao_Paulo" to the cron.schedule options. Without it, it uses UTC.
- Problem The worker stops running after a few hours
- Solution Check for proper error handling. An unhandled rejected promise kills the Node process. Add process.on("unhandledRejection").
- Problem The container restarts but logs show nothing
- Solution The Dockerfile might be running the wrong command. Verify that CMD points to the worker file, not the API.
- Problem Two jobs run at the same time and conflict in the database
- Solution Use a distributed lock (Advisory Locks in Postgres) or chain the jobs sequentially using async/await.
- Problem Job takes longer than the interval and executions stack up
- Solution Add a "running" flag that prevents overlap. Only start the next execution when the current one finishes.
Alternatives to node-cron
For specific contexts, other tools make more sense:
BullMQ + Redis: when you need retry, delay, priority, or dead-letter queues. Ideal for async processing with delivery guarantees.
Agenda + MongoDB: similar to BullMQ but uses Mongo as the backend. Good if the project already has MongoDB and you do not want to introduce Redis.
External cron (GitHub Actions, cron-job.org): for very simple tasks that do not need application context. Example: a healthcheck ping every 5 minutes.
The choice depends on what you need to guarantee. If it is just “run at 8 AM daily and log failures”, node-cron handles it. If you need retry and queues, go with BullMQ.
Can I run cron jobs in the same container as the API?
You can, but you should not. If a job blocks the event loop or consumes too much memory, the API suffers too. Separate services let you scale and debug independently.
What happens if the container restarts in the middle of a job?
The job gets interrupted and does not resume from where it stopped. If the task is idempotent (can run twice without issues), that is fine. Otherwise, use a database lock to mark "in progress" and check before starting.
How many simultaneous jobs can node-cron handle?
There is no hardcoded limit. The bottleneck is what each job does. If all 10 jobs are quick database queries, it runs fine. If each job makes heavy HTTP calls, consider distributing across separate workers.
How do I test cron jobs locally without waiting for the schedule?
Export the job logic into separate functions and test the functions directly. To test the scheduling, use short intervals (every 10 seconds) during development.
Deploy your cron jobs on Guara Cloud
Workers running 24/7 with automatic restarts, centralized logs, and BRL billing. No server management.