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.
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:
- Stores
ROVELA_API_KEY,MAIL_FROM, and recipients in a Secret / ConfigMap — never in the image or a public Git tree - Ships a one-shot container command that starts, sends, and exits (Jobs are not long-running Deployments)
- Aggregates a digest / report for the current schedule window
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives Job retries,
concurrencyPolicyoverlaps, and manualkubectl 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:
- DigitalOcean App Platform Jobs Email — App Platform
kind: SCHEDULEDJob (≥15 min cron +time_zone), notbatch/v1CronJob - Heroku Scheduler Email — free Scheduler add-on, coarse 10-min/hourly/daily one-off dynos
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled() - Azure Functions Timer Email — Azure NCRONTAB (seconds-first) + App Settings / Key Vault
- GCP Cloud Scheduler Email — Cloud Scheduler + HTTP/OIDC or Pub/Sub
- AWS EventBridge Scheduler Email — six-field AWS cron /
rate/at+ Lambda - Netlify Scheduled Functions Email — Netlify UTC cron + short function limit
- Fly Scheduled Machines Email — coarse Machine intervals
- Render Cron Transactional Email — Render Cron Job process that must exit
- Railway Cron Transactional Email — Railway cron service
- Vercel Cron Digest Email — HTTP
GETto a production route +CRON_SECRET - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
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
| Concern | Owner |
|---|---|
Five-field cron + optional .spec.timeZone | CronJob (CronJob concept, API) |
| Create Job ≈ once per schedule; Pod runs to completion | CronJob → Job → Pod |
Secret / env for ROVELA_API_KEY (sending_access) | Secret + secretKeyRef / envFrom (Secrets, inject credentials) |
| Query DB / APIs for the window | Your container |
| Render HTML + text | Your container |
POST /emails, DKIM, queue, bounces | Rovela |
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.scheduleis 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 asEtc/UTCorAmerica/New_York. Without it, the schedule follows the kube-controller-manager’s local zone. Do not embedCRON_TZ/TZinside.spec.schedule— that form is unsupported and rejected (time zones).- Job template Pod
restartPolicymust beNeverorOnFailure. PreferNeverfor a short HTTPS send so a failed attempt becomes a new Pod under the Job’sbackoffLimit, and yourIdempotency-Keycollapses duplicates (Jobs). concurrencyPolicy:Allow(default) can run overlapping Jobs;Forbidskips a tick if the previous Job is still running;Replacecancels the running Job and starts a new one (concurrency policy). For digests,Forbidplus 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-Keyis 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) andfailedJobsHistoryLimit(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_accesskey 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 /emailson 443 from the Pod (Email API vs SMTP). Ensure NetworkPolicy / egress allowsapi.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
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the Job Pod (Authentication). - A cluster with
batch/v1CronJob support and outbound HTTPS from Job Pods. - A Secret (and optional ConfigMap) for
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO. - A container image whose command exits
0after 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
backoffLimitcan 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 withReplace) can re-enter the same calendar window
Recommended keys:
| Key | Use 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
- Build/push the image; apply Secret + CronJob.
- Confirm the Secret is mounted only as env (not printed in
kubectl describevalue fields when usingsecretKeyRef). - 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
| Symptom | Likely cause | What to do |
|---|---|---|
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix Secret; confirm sending_access (Authentication) |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains (Quick Start) |
Rovela 429 | Rate limit | Back off and retry with the same Idempotency-Key |
| CronJob never creates Jobs | suspend: true, invalid schedule, or timezone validation error | kubectl describe cronjob; check Events for UnknownTimeZone |
| Duplicate digests | Retry / manual Job in the same window without a stable key | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Pod CrashLoop / Job fails | Command never exits, OOM, or egress blocked | Exit after send; raise memory; allow api.rovela.dev:443 |
| Secret visible in Git | Key in ConfigMap or checked-in YAML | Use Secret + secretKeyRef; prefer sealed-secrets / external secrets operator |
| Overlapping sends | concurrencyPolicy: Allow with a slow Job | Prefer Forbid; keep wall time short |
Production checklist
-
Verified Rovela sending domain;
MAIL_FROMmatches it -
ROVELA_API_KEYissending_access, stored in a Secret, injected viasecretKeyRef/envFrom -
CronJob
batch/v1with five-fieldscheduleand explicittimeZonewhen the controller supports it -
Pod
restartPolicy: Never(orOnFailure); JobbackoffLimitbounded -
concurrencyPolicy: Forbid(or documented Allow + idempotent keys) - Container exits after send (or after a deliberate empty-window no-op)
-
Idempotency-Keyscoped 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.complainedon 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.