NestJS cron on Kubernetes: CronJob instead of every pod
@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. ExtraforRoot()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.
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 podPatching 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-dbThe 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 onlyIf 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: trueon@Cron()only helps overlap inside one process; it does not fix multiple pods.- Dynamic
SchedulerRegistryjobs 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 fullAppModule.
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.