Back to blog Tutorials

How to run cron jobs in Node.js with Docker in production

A practical guide to scheduling tasks in Node.js using node-cron, containerizing with Docker, and deploying with logs and monitoring.

8 min read

By Guara Cloud Editorial

Tested with Node.js 20 / Docker / node-cron 3.x

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-cron for 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

  1. Create a new service on Guara Cloud
  2. Choose deploy via GitHub (pointing to your repository) or Docker image
  3. Set the run command to npm run worker (or access the container directly)
  4. Configure environment variables (DATABASE_URL, TZ, etc)
  5. Start the deploy and confirm in the logs that the worker started
Deploy worker via CLI
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.

Start free