← Back to blog

Kubernetes CronJob Transactional Email with Rovela (2026): Five-Field Schedule + Secret Env + Idempotency-Key per Window

Wire Kubernetes CronJob (batch/v1, five-field cron + timeZone) to Rovela POST /emails: Secret/secretKeyRef for sending_access, Job Pod start→exit, and a window-scoped Idempotency-Key so retries never duplicate digests.

Updated November 5, 2026
Kubernetes CronJob Transactional Email with Rovela (2026): Five-Field Schedule + Secret Env + Idempotency-Key per Window

Kubernetes CronJob owns the schedule — Rovela delivers the mail

Kubernetes CronJob (batch/v1, stable since v1.21) creates a one-shot Job on a repeating five-field cron schedule (minute hour day-of-month month day-of-week), optionally with .spec.timeZone (stable since v1.27). Each Job starts Pods that run a container to completion (restartPolicy: Never or OnFailure) and then exit. That start→exit shape fits digests, ops reports, and other transactional mail whose body depends on live cluster or app state at fire time — signup counts, failed Jobs, unpaid invoices — rather than content you can freeze hours earlier with Rovela scheduled_at.

Rovela queues, DKIM-signs, and delivers when the Job container calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Kubernetes owns the CronJob schedule, Job/Pod lifecycle, and Secrets (or env) for sending_access. Your container owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires Kubernetes CronJob → Rovela that:

  1. Stores ROVELA_API_KEY, MAIL_FROM, and recipients in a Secret / ConfigMap — never in the image or a public Git tree
  2. Ships a one-shot container command that starts, sends, and exits (Jobs are not long-running Deployments)
  3. Aggregates a digest / report for the current schedule window
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. Survives Job retries, concurrencyPolicy overlaps, and manual kubectl create job --from=cronjob/… without duplicate mail

Rovela does not create CronJobs, Jobs, Pods, or Secrets. Kubernetes owns schedule → Job → Pod start/exit. Your container owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

Also distinct from a long-lived Deployment / queue consumer that happens to send mail — those stay up. This post is the batch/v1 CronJob → Job Pod → exit → Rovela path.

What Kubernetes CronJob does vs what Rovela sends

ConcernOwner
Five-field cron + optional .spec.timeZoneCronJob (CronJob concept, API)
Create Job ≈ once per schedule; Pod runs to completionCronJob → Job → Pod
Secret / env for ROVELA_API_KEY (sending_access)Secret + secretKeyRef / envFrom (Secrets, inject credentials)
Query DB / APIs for the windowYour container
Render HTML + textYour container
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = k8s-cron-{kind}:{yyyy-mm-dd} (or hour / schedule bucket)Your container

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

CronJob constraints that shape the email Job

From Kubernetes docs, treat these as hard requirements for a mail-sending CronJob:

  • .spec.schedule is five-field cron (optionally macros like @daily). Example: 0 13 * * * = 13:00 every day in the CronJob’s time zone (schedule syntax).
  • .spec.timeZone (stable since v1.27) names a tz-database zone such as Etc/UTC or America/New_York. Without it, the schedule follows the kube-controller-manager’s local zone. Do not embed CRON_TZ / TZ inside .spec.schedule — that form is unsupported and rejected (time zones).
  • Job template Pod restartPolicy must be Never or OnFailure. Prefer Never for a short HTTPS send so a failed attempt becomes a new Pod under the Job’s backoffLimit, and your Idempotency-Key collapses duplicates (Jobs).
  • concurrencyPolicy: Allow (default) can run overlapping Jobs; Forbid skips a tick if the previous Job is still running; Replace cancels the running Job and starts a new one (concurrency policy). For digests, Forbid plus a window-scoped key is usually safest.
  • Jobs should be idempotent. Kubernetes documents that under some conditions a CronJob may create two Jobs (or none) for a schedule — design the send so a duplicate fire is safe (Job creation). Rovela’s Idempotency-Key is the email-side half of that contract.
  • startingDeadlineSeconds: optional deadline for late starts; missed deadlines count as failed. Values under ~10 seconds can miss the controller’s check interval (limitations).
  • History limits: successfulJobsHistoryLimit (default 3) and failedJobsHistoryLimit (default 1) retain finished Jobs for debugging.
  • Name length: CronJob name ≤ 52 characters so Job names stay within the 63-character limit after the controller’s suffix (CronJob).
  • Secrets stay server-side. Prefer a Rovela sending_access key in a Secret — never bake it into an image layer or ConfigMap checked into Git (Authentication).
  • Outbound SMTP is a poor fit. Prefer HTTPS POST /emails on 443 from the Pod (Email API vs SMTP). Ensure NetworkPolicy / egress allows api.rovela.dev:443.

When to use CronJob + POST /emails (not scheduled_at)

Use CronJob → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run workloads on Kubernetes and want the same RBAC, Secrets, and kubectl / metrics pipeline
  • Five-field cron (with optional timeZone) is enough
  • One (or a few) transactional messages go to ops / founder / customer addresses looked up at run time

Use Rovela scheduled_at when the body is already correct and you only need “deliver later” without a running Pod (Scheduled Email Sending). CronJob and scheduled_at solve different problems; this post is the Kubernetes path.

Use a long-lived Deployment / worker only when you need a continuous process (queue consumer). For calendar digests, a CronJob that exits is the clearer fit.

Prerequisites

  1. A verified sending domain in Rovela. Unverified from domains are refused with 400 (Quick Start).
  2. An API key shaped like re_…. Prefer sending_access for the Job Pod (Authentication).
  3. A cluster with batch/v1 CronJob support and outbound HTTPS from Job Pods.
  4. A Secret (and optional ConfigMap) for ROVELA_API_KEY, MAIL_FROM, DIGEST_TO.
  5. A container image whose command exits 0 after a successful accept (or a deliberate no-op when there is nothing to mail).

Manifests: Secret + CronJob

Create the Secret first (do not commit the literal key):

kubectl create secret generic rovela-sending \
  --from-literal=ROVELA_API_KEY='re_your_sending_access_key' \
  --dry-run=client -o yaml | kubectl apply -f -

Minimal CronJob (adjust image, namespace, and schedule):

apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-digest
  labels:
    app: daily-digest
spec:
  schedule: "0 13 * * *"          # 13:00 in timeZone below
  timeZone: America/New_York      # requires cluster support (stable since v1.27)
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  startingDeadlineSeconds: 300
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        metadata:
          labels:
            app: daily-digest
        spec:
          restartPolicy: Never
          containers:
            - name: send-digest
              image: ghcr.io/your-org/digest-mailer:1.0.0
              imagePullPolicy: IfNotPresent
              command: ["node", "bin/send-daily-digest.js"]
              env:
                - name: MAIL_FROM
                  value: "Acme Reports <reports@mail.example.com>"
                - name: DIGEST_TO
                  value: "founders@example.com"
                - name: DIGEST_KIND
                  value: daily
                - name: ROVELA_API_KEY
                  valueFrom:
                    secretKeyRef:
                      name: rovela-sending
                      key: ROVELA_API_KEY
              resources:
                requests:
                  cpu: 50m
                  memory: 64Mi
                limits:
                  cpu: 200m
                  memory: 128Mi

Apply:

kubectl apply -f cronjob-daily-digest.yaml
kubectl get cronjob daily-digest

Every-15-minutes ops pulse example (UTC):

spec:
  schedule: "*/15 * * * *"
  timeZone: Etc/UTC
  concurrencyPolicy: Forbid

Prefer sending_access on production senders — never full_access (Authentication). Never log the API key. Rotate by creating a new Rovela key, updating the Secret, and letting the next Job pick it up (env from Secrets is fixed at Pod start).

One-shot Node.js script (window-scoped Idempotency-Key)

Add a script your Job container can execute:

// bin/send-daily-digest.js
'use strict';

const ROVELA_URL = 'https://api.rovela.dev/emails';

function requireEnv(name) {
  const v = process.env[name];
  if (!v) throw new Error(`missing env ${name}`);
  return v;
}

function utcDayKey(d = new Date()) {
  return d.toISOString().slice(0, 10); // YYYY-MM-DD
}

function utcHourKey(d = new Date()) {
  return d.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}

function windowKey(kind) {
  if (kind === 'hourly') return `k8s-cron-hourly-ops:${utcHourKey()}`;
  if (kind === 'fifteen') {
    const d = new Date();
    const bucket = Math.floor(d.getUTCMinutes() / 15) * 15;
    const hh = String(d.getUTCHours()).padStart(2, '0');
    const mm = String(bucket).padStart(2, '0');
    return `k8s-cron-fifteen-ops:${utcDayKey()}T${hh}:${mm}`;
  }
  return `k8s-cron-daily-digest:${utcDayKey()}`;
}

async function buildDailyDigest(day) {
  // Replace with your queries against Postgres / Redis / APIs
  const stats = { signups: 12, paid: 3, incidents: 1, failedJobs: 0 };
  const subject = `Acme daily digest — ${day}`;
  const text = [
    `Signups: ${stats.signups}`,
    `Paid: ${stats.paid}`,
    `Open incidents: ${stats.incidents}`,
    `Failed jobs: ${stats.failedJobs}`,
  ].join('\n');
  const html = `
    <h1>Daily digest — ${day}</h1>
    <ul>
      <li>Signups: <b>${stats.signups}</b></li>
      <li>Paid: <b>${stats.paid}</b></li>
      <li>Open incidents: <b>${stats.incidents}</b></li>
      <li>Failed jobs: <b>${stats.failedJobs}</b></li>
    </ul>
    <p><a href="https://app.example.com/admin">Open admin</a></p>
  `;
  return { subject, html, text, stats };
}

async function sendTransactional({ to, subject, html, text, idempotencyKey, tags }) {
  const res = await fetch(ROVELA_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({
      from: requireEnv('MAIL_FROM'),
      to,
      subject,
      html,
      text,
      tags,
    }),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`);
  }
  return body; // { object: 'email', id }
}

async function main() {
  const kind = process.env.DIGEST_KIND || 'daily';
  const day = utcDayKey();
  const digest = await buildDailyDigest(day);
  const to = requireEnv('DIGEST_TO')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean);
  const idempotencyKey = windowKey(kind);

  console.log(
    JSON.stringify({
      kind,
      day,
      idempotencyKey,
      // never log ROVELA_API_KEY
    }),
  );

  const result = await sendTransactional({
    to,
    subject: digest.subject,
    html: digest.html,
    text: digest.text,
    idempotencyKey,
    tags: {
      kind: `k8s-cron-${kind}-digest`,
      day,
      platform: 'kubernetes-cronjob',
    },
  });

  console.log(JSON.stringify({ ok: true, emailId: result.id }));
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Rovela’s Idempotency-Key (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API). That matters on Kubernetes because:

  • Job backoffLimit can start a new Pod after a container exit ≠ 0
  • CronJob may create more than one Job for a schedule under rare controller conditions (Job creation)
  • Manual kubectl create job --from=cronjob/daily-digest manual-$(date +%s) can overlap a scheduled fire
  • concurrencyPolicy: Allow (or a slow previous Job with Replace) can re-enter the same calendar window

Recommended keys:

KeyUse when
k8s-cron-daily-digest:{yyyy-mm-dd}One ops digest per UTC day
k8s-cron-hourly-ops:{yyyy-mm-dd}T{HH}Hourly snapshot (0 * * * *)
k8s-cron-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm}Every-15-minutes pulse (mm floored to 00/15/30/45)
k8s-cron-weekly-report:{yyyy}-W{ww}One founder report per ISO week
k8s-cron-customer-digest:{userId}:{yyyy-mm-dd}Per-user digests (one key per recipient window)

If you mail many personalized digests, loop with a fresh key per recipient (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design (Send Email API)). Cap concurrency; on 429, back off and retry with the same key. Keep wall time short so the Pod can exit cleanly.

Optionally derive the window from the Job annotation batch.kubernetes.io/cronjob-scheduled-timestamp (RFC3339, added in Kubernetes v1.32) when you need the controller’s intended fire time rather than “now” inside a late Pod.

Python alternative

#!/usr/bin/env python3
# bin/send_daily_digest.py
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone

ROVELA_URL = "https://api.rovela.dev/emails"


def require_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        raise SystemExit(f"missing env {name}")
    return v


def utc_day_key(now: datetime | None = None) -> str:
    now = now or datetime.now(timezone.utc)
    return now.strftime("%Y-%m-%d")


def window_key(kind: str) -> str:
    now = datetime.now(timezone.utc)
    if kind == "hourly":
        return f"k8s-cron-hourly-ops:{now.strftime('%Y-%m-%dT%H')}"
    if kind == "fifteen":
        bucket = (now.minute // 15) * 15
        return f"k8s-cron-fifteen-ops:{now.strftime('%Y-%m-%dT%H')}:{bucket:02d}"
    return f"k8s-cron-daily-digest:{utc_day_key(now)}"


def build_daily_digest(day: str) -> dict:
    stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
    subject = f"Acme daily digest — {day}"
    text = (
        f"Signups: {stats['signups']}\n"
        f"Paid: {stats['paid']}\n"
        f"Open incidents: {stats['incidents']}\n"
        f"Failed jobs: {stats['failedJobs']}"
    )
    html = f"""
    <h1>Daily digest — {day}</h1>
    <ul>
      <li>Signups: <b>{stats['signups']}</b></li>
      <li>Paid: <b>{stats['paid']}</b></li>
      <li>Open incidents: <b>{stats['incidents']}</b></li>
      <li>Failed jobs: <b>{stats['failedJobs']}</b></li>
    </ul>
    <p><a href="https://app.example.com/admin">Open admin</a></p>
    """
    return {"subject": subject, "html": html, "text": text}


def send_transactional(*, to, subject, html, text, idempotency_key, tags):
    payload = {
        "from": require_env("MAIL_FROM"),
        "to": to,
        "subject": subject,
        "html": html,
        "text": text,
        "tags": tags,
    }
    req = urllib.request.Request(
        ROVELA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.load(resp)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        raise SystemExit(f"Rovela {e.code}: {body}") from e


def main() -> None:
    kind = os.environ.get("DIGEST_KIND", "daily")
    day = utc_day_key()
    digest = build_daily_digest(day)
    to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
    idem = window_key(kind)
    print(json.dumps({"kind": kind, "day": day, "idempotencyKey": idem}))
    result = send_transactional(
        to=to,
        subject=digest["subject"],
        html=digest["html"],
        text=digest["text"],
        idempotency_key=idem,
        tags={
            "kind": f"k8s-cron-{kind}-digest",
            "day": day,
            "platform": "kubernetes-cronjob",
        },
    )
    print(json.dumps({"ok": True, "emailId": result.get("id")}))


if __name__ == "__main__":
    try:
        main()
    except Exception as err:
        print(err, file=sys.stderr)
        sys.exit(1)

Set the container command to ["python", "bin/send_daily_digest.py"] (or your image’s interpreter path).

Attach and verify the CronJob

  1. Build/push the image; apply Secret + CronJob.
  2. Confirm the Secret is mounted only as env (not printed in kubectl describe value fields when using secretKeyRef).
  3. Watch the next fire, or create a one-off Job from the CronJob:
kubectl create job --from=cronjob/daily-digest daily-digest-manual-1
kubectl logs job/daily-digest-manual-1 -f
kubectl get jobs -l app=daily-digest

You should see the Pod start, your JSON log line with idempotencyKey, exit 0, and a Rovela emailId. Store that id if you correlate against webhooks later.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Rovela 401Bad / missing ROVELA_API_KEYFix Secret; confirm sending_access (Authentication)
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains (Quick Start)
Rovela 429Rate limitBack off and retry with the same Idempotency-Key
CronJob never creates Jobssuspend: true, invalid schedule, or timezone validation errorkubectl describe cronjob; check Events for UnknownTimeZone
Duplicate digestsRetry / manual Job in the same window without a stable keyUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
Pod CrashLoop / Job failsCommand never exits, OOM, or egress blockedExit after send; raise memory; allow api.rovela.dev:443
Secret visible in GitKey in ConfigMap or checked-in YAMLUse Secret + secretKeyRef; prefer sealed-secrets / external secrets operator
Overlapping sendsconcurrencyPolicy: Allow with a slow JobPrefer Forbid; keep wall time short

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, stored in a Secret, injected via secretKeyRef / envFrom
  • CronJob batch/v1 with five-field schedule and explicit timeZone when the controller supports it
  • Pod restartPolicy: Never (or OnFailure); Job backoffLimit bounded
  • concurrencyPolicy: Forbid (or documented Allow + idempotent keys)
  • Container exits after send (or after a deliberate empty-window no-op)
  • Idempotency-Key scoped to the schedule window (day / hour / 15-min bucket)
  • kubectl logs / Job status show successful completions; optional alerts on failed Jobs
  • NetworkPolicy allows egress to Rovela HTTPS
  • Optional: Rovela webhooks for email.bounced / email.complained on digest recipients

FAQ

Can I put CRON_TZ=America/New_York in the schedule string?
No — Kubernetes rejects TZ / CRON_TZ inside .spec.schedule. Use .spec.timeZone instead (time zones).

Should I use Rovela scheduled_at from the Job Pod?
Usually no. If the CronJob already fires at send time with a live body, call POST /emails immediately. Use scheduled_at when you accept the message now and want Rovela to deliver later without a Pod awake (Scheduled Email Sending).

CronJob vs Deployment vs plain Job for digests?
Use a CronJob for calendar digests that create Jobs on a schedule. Use a one-off Job for a single manual send. Use a Deployment for continuous queue consumers — not for “once a day” mail.

What key scope does sending_access cover?
Enough to queue mail via POST /emails / batch. Reading messages, rescheduling, and cancel need full_access or a dashboard session (Send Email API, Authentication). Keep the Job Pod on sending_access.

Does a 200 mean the founder already got the digest?
No — it means Rovela accepted and queued the message. Use GET /emails/:id, events, or webhooks for delivery state (Quick Start).

Why does Kubernetes say Jobs must be idempotent?
Because a CronJob can, in rare cases, create two Jobs for one schedule (or skip one). Your send path — especially the Rovela Idempotency-Key — must tolerate that (Job creation).

Ship the schedule; let Rovela deliver

Kubernetes CronJob gives you first-class five-field cron (with optional timeZone) on short-lived Job Pods. Your container owns the live digest and a window-scoped Idempotency-Key. Rovela owns HTTPS accept, DKIM, and delivery. Keep the sending key in a Secret, exit cleanly, and you get transactional digests without standing up SMTP or a custom mail stack inside the cluster.

Next: verify a domain, create a sending_access key, apply a Secret + CronJob, deploy the one-shot image, and confirm the first Job with kubectl logs.