Blog
NestJSKubernetesCronTypeScript

NestJS cron on Kubernetes: CronJob instead of every pod

·8 min read

@nestjs/schedule runs cron inside the Node process. In Kubernetes, a Deployment with replicas: 3 means three independent schedulers. At 00:00 each pod fires the same @Cron() handler — three charges, three e-mails, three reconciliation runs. Horizontal Pod Autoscaler makes the count dynamic; the bug scales with it.

This study is about one alternative that matches how the cluster thinks about time: take cron out of the API pods and trigger work with a Kubernetes CronJob, using Nest’s standalone application context so you still get DI without listen() and without ScheduleModule on every replica.

Monorepos make the mistake easier (shared package exports BillingModule with @Cron()), but the root cause in production is almost always replicated pods, not TypeScript.

What Nest does when the pod starts

From the task scheduling guide:

  • ScheduleModule.forRoot() must appear once per Nest application. Extra forRoot() calls re-register every @Cron(), @Interval(), and @Timeout() in that process.
  • Jobs attach on onApplicationBootstrap, after the DI graph is ready.
  • Each running process is a full scheduler. Kubernetes does not deduplicate that for you.
So this manifest is a multiplication machine:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: billing-api
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: api
          image: billing-api:latest
          # Same image = same AppModule = same @Cron() on every pod

Patching with replicas: 1 on the API removes the symptom and removes HA. Gating with disabled: process.env.IS_PRIMARY on a random pod is fragile after restarts and scale events. The alternative is to split deployables: API pods handle HTTP only; something else owns the clock.

The alternative: cluster CronJob + Nest one-shot context

Idea: the API Deployment never imports ScheduleModule. Shared code exposes plain services (BillingService.chargeDueInvoices()). A Kubernetes CronJob creates a new pod on the schedule; that pod bootstraps a lean Nest module, runs the use case once, exits.

You get exactly one execution per tick when concurrencyPolicy: Forbid (default in many setups is Allow — set it explicitly).

apiVersion: batch/v1
kind: CronJob
metadata:
  name: billing-charge-due
spec:
  schedule: "0 1 * * *"   # 01: 00 UTC — adjust timezone at app or use CRD with TZ
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 3600
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: job
              image: billing-jobs:latest
              command: ["node", "dist/charge-due.main.js"]
              envFrom:
                - secretRef:
                    name: billing-db

The entrypoint follows the standalone doc pattern — bootstrap, work, close:

// apps/billing-jobs/src/charge-due.main.ts
import { NestFactory } from '@nestjs/core';
import { ChargeDueModule } from './charge-due.module';
import { BillingService } from '@org/billing'; // shared lib, no @Cron

async function bootstrap() {
  const app = await NestFactory.createApplicationContext(ChargeDueModule, {
    logger: ['error', 'warn', 'log'],
  });

  try {
    await app.get(BillingService).chargeDueInvoices();
  } finally {
    await app.close(); // triggers shutdown hooks; pod can exit 0
  }
}

bootstrap().catch((err) => {
  console.error(err);
  process.exit(1);
});
@Module({
  imports: [
    BillingModule, // from monorepo package — domain + infra, zero decorators
  ],
})
export class ChargeDueModule {}

Why this is the focus of the study

| Concern | @Cron on API Deployment | K8s CronJob + Nest context | |--------|-------------------------|----------------------------| | N API replicas | N runs per tick | 1 Job pod per tick | | HPA scales API | More duplicate runs | Unchanged | | Failed run | Easy to miss among API logs | Job status / backoff visible in kubectl | | Nest DI | Yes | Yes, per Job pod | | Long-lived timer in API | Yes | No timer in API at all |

The image can be the same monorepo build with a different command, or a slim billing-jobs target that only bundles job entrypoints. What matters is the API container never calls ScheduleModule.forRoot().

Monorepo rule (still required)

Keep @Cron() out of packages imported by the HTTP app:

packages/billing/     → BillingService, BillingModule (no schedule)
apps/api/             → AppModule, HTTP, NO ScheduleModule
apps/billing-jobs/    → charge-due.main.ts + ChargeDueModule only

If apps/api still imports a module that registers @Cron(), you are back to N timers when N pods run — even with a CronJob in the cluster.

When you still want @nestjs/schedule

For sub-minute ticks, many dynamic schedules via SchedulerRegistry, or local dev parity, a dedicated Deployment billing-scheduler with replicas: 1 and a long-lived createApplicationContext (no app.close()) is valid. Pin replicas in the manifest; do not colocate it with the API Deployment.

async function bootstrap() {
  await NestFactory.createApplicationContext(SchedulerAppModule);
  // Process stays up; @Cron inside SchedulerAppModule only
}
bootstrap();

That is a second deployable, not replicas: 1 on the same chart as the API. For daily/hourly batch work, CronJob + one-shot context is usually simpler and aligns with Kubernetes semantics.

Doc footnotes worth keeping

  • waitForCompletion: true on @Cron() only helps overlap inside one process; it does not fix multiple pods.
  • Dynamic SchedulerRegistry jobs are in-memory; reload after restart. They still multiply per scheduler pod.
  • One forRoot() per Nest app — never call it in both API and a nested script that imports the full AppModule.

Checklist

1. API Deployment: scale freely, no ScheduleModule. 2. Shared libs: no @Cron() on modules the API imports. 3. Scheduled work: CronJob → lean Nest module → app.get(Service).method() → app.close(). 4. Set concurrencyPolicy: Forbid and activeDeadlineSeconds on Jobs. 5. Use a separate scheduler Deployment with replicas: 1 only if CronJob granularity is not enough.

The Nest docs describe how to run cron in a process. Kubernetes runs many copies of that process. The alternative is not a clever decorator — it is moving the schedule to the control plane and using Nest where it shines: a short-lived DI container per run, not a hidden timer on every API pod.