Back to guides

Guides · May 12, 2026

Docker cron jobs: the four patterns and how to pick the right one

Docker Compose has no cron or schedule key. Four patterns with copy-paste Dockerfiles and YAML, fixes for the env, log, and timezone problems that make container cron go quiet, and heartbeat monitoring for missed runs.
crontap.com / guides
Yellow shipping container with a centered clock and an incoming trigger arrow on a deep teal background.

To run a cron job in Docker, copy a crontab into the image and run the scheduler in the foreground as the container's main process. That means cron -f on Debian or Ubuntu, crond -f -d 8 on Alpine, or supercronic on any base image. Docker itself has no built-in job scheduler and Compose has no schedule key, so in Compose you add a scheduler service next to your app instead.

On Kubernetes or Swarm, let the orchestrator own the clock. If the job is already an HTTP endpoint, an external cron can call it, so your images stay single-purpose. On Compose and short on time? Pattern 2 opens with a scheduler service that reuses your app's image.

Pick a pattern by asking who owns the clock

The Compose services reference has no schedule or cron attribute, so something else has to keep time. These are the four ways to run scheduled tasks in Docker, also compared side by side:

Pattern 1: run cron inside the container

This works once the scheduler runs in the foreground as PID 1 (the container's main process), jobs can see their environment, and their output reaches docker logs. Bake the crontab into the image or mount it, because anything you change with crontab -e in a running container disappears on the next deploy.

Give the scheduler its own container. Docker recommends one service per container, and an app with cron inside runs every job once per replica. If a container must run several processes, use docker run --init or Compose init: true. That adds a tiny init process as PID 1 that forwards stop signals and cleans up finished child processes, so they don't linger as zombies.

The samples assume your job is a script at /app/nightly.sh, copied with --chmod=755 so it still runs if git or Windows dropped the executable bit. The cron syntax cheat sheet covers the five schedule fields.

Where the crontab goes in a Docker image

Most "my Docker crontab does nothing" questions come down to where the file lives and what format the daemon expects.

  • Debian or Ubuntu system file: /etc/cron.d/<name>. It needs a user field, must be owned by root and not group- or other-writable, and the name cannot contain dots.
  • Debian or Ubuntu user crontab: crontab /app/crontab installs the file into /var/spool/cron/crontabs/. No user field.
  • Alpine: /etc/crontabs/root, owned by root. No user field.
  • supercronic: any path you pass as its argument. No user field.

Mounting works, with one catch: a bind-mounted file keeps its owner from the host, and both daemons insist on root. Debian cron(8) ignores /etc/cron.d files that root doesn't own, and BusyBox crond skips a crontab that isn't owned by root without logging anything. Run sudo chown root:root crontab && sudo chmod 644 crontab on the host, then mount it, for example ./crontab:/etc/crontabs/root:ro on Alpine.

Debian or Ubuntu: install cron and use /etc/cron.d

debian:trixie-slim ships without cron, curl, or wget, but it does include tzdata, the timezone database under /usr/share/zoneinfo. Debian cron reads its timezone from /etc/localtime and stays in the foreground with -f (cron(8)), so link that file to your zone.

FROM debian:trixie-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends cron curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*
ENV TZ=Europe/Berlin
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
COPY --chmod=755 nightly.sh /app/nightly.sh
COPY --chmod=755 entrypoint.sh /entrypoint.sh
COPY --chmod=644 nightly-cron /etc/cron.d/nightly-cron
ENTRYPOINT ["/entrypoint.sh"]

The official ubuntu:24.04 image ships neither cron nor tzdata as of October 2026, so on Ubuntu the ln line points at nothing. Add tzdata to the install line and run it as DEBIAN_FRONTEND=noninteractive apt-get install -y ..., so the region prompt can't stall the build.

Debian cron gives each job only SHELL, LOGNAME, HOME, and PATH, so variables from docker run -e or Compose never arrive. Its PAM config (the session setup Debian runs before each job) does load /etc/environment, so the usual entrypoint.sh writes the container's environment there first:

#!/bin/sh
set -e
printenv > /etc/environment
exec cron -f

That has a cost: secrets end up in a file inside the container, and values with quotes or newlines may not survive the simple KEY=VALUE parser that reads it. For secrets, Compose secrets are cleaner: each one is mounted as a file at /run/secrets/<name>, which a job can read whatever environment cron gives it.

Debian cron also mails job output to the crontab owner, and with no mail server in the container that output vanishes. docker logs shows the main process's stdout and stderr (Docker logging docs), so redirect each job to PID 1's stdout:

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * root /app/nightly.sh >> /proc/1/fd/1 2>&1

Name the file nightly-cron, with no dots, and end it with a newline, since crontab(5) treats a last entry that ends at EOF as broken. The redirect works because the job runs as root. PATH= lines are taken literally, with no $VAR expansion, which is how jobs end up running the wrong Python (see Python cron jobs).

Alpine cron: BusyBox crond and /etc/crontabs/root

Alpine uses BusyBox crond. Its default root crontab runs run-parts over /etc/periodic/15min, hourly, daily, weekly, and monthly. If you keep that file instead of copying your own over it, an executable script in /etc/periodic/daily runs once a day, as long as its name has no dot (backup, not backup.sh).

FROM alpine:3.24
RUN apk add --no-cache curl tzdata
ENV TZ=Europe/Berlin
COPY --chmod=755 nightly.sh /app/nightly.sh
COPY crontab /etc/crontabs/root
CMD ["crond", "-f", "-d", "8"]
0 2 * * * /app/nightly.sh

-f keeps crond in the foreground, and -d 8 sends crond's own log to stderr, where docker logs sees it. Without -d or -L /dev/stdout, crond logs to syslog, so the common crond -f -l 2 setup hides its messages (BusyBox docs).

BusyBox crond passes its own environment through to jobs, and with no MAILTO set, job output reaches docker logs without a redirect. Time is the catch: the Alpine base image has no tzdata and no /etc/localtime, so TZ=Europe/Berlin without tzdata silently stays on UTC.

Both Alpine and Debian ship flock and timeout. A job command like flock -n /tmp/nightly.lock timeout 3600 /app/nightly.sh skips a run while the previous one holds the lock, and kills a run that hangs for more than an hour.

Supercronic: cron built for containers

Supercronic is "a crontab-compatible job runner, designed specifically to run in containers." Its README explains why classic crons fail here: they "purge their environment before starting jobs," which "breaks a fundamental configuration mechanism for containers." Supercronic keeps your environment, logs job output to stdout and stderr, and shuts down gracefully on SIGTERM.

FROM debian:trixie-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*
 
# Latest releases available at https://github.com/aptible/supercronic/releases
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.49/supercronic-linux-amd64 \
    SUPERCRONIC_SHA1SUM=e63c11a9726b775a6a11801e81af4f3fb926aa68 \
    SUPERCRONIC=supercronic-linux-amd64
 
RUN curl -fsSLO "$SUPERCRONIC_URL" \
 && echo "${SUPERCRONIC_SHA1SUM}  ${SUPERCRONIC}" | sha1sum -c - \
 && chmod +x "$SUPERCRONIC" \
 && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
 && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic
 
COPY --chmod=755 nightly.sh /app/nightly.sh
COPY crontab /app/crontab
RUN supercronic -test /app/crontab
CMD ["supercronic", "/app/crontab"]
CRON_TZ=Europe/Berlin
0 2 * * * /app/nightly.sh

The install stanza is copied from the v0.2.49 release notes, the latest release as of October 2026. Many guides still pin v0.2.33. For arm64, change amd64 to arm64 in SUPERCRONIC_URL and SUPERCRONIC, and use SHA1 0b6c5bb743e0b0dafed1132198c81807927ac413 from the same notes.

supercronic -test fails the build on a bad crontab. Supercronic waits for a job to finish before starting it again (-overlapping allows overlap), reloads on SIGUSR2, and honors CRON_TZ lines or TZ, which needs tzdata. It never switches users, so unlike Debian cron and BusyBox crond it runs fine under a non-root USER.

Pattern 2: Docker Compose cron with a scheduler service

The idiomatic way to run a scheduled job in Docker Compose is a second service whose only job is the clock. The app scales and redeploys on its own, the scheduler stays at one replica, and both share the same config.

Same image, different command: a supercronic scheduler service

Add the supercronic install from above and COPY crontab /app/crontab to your app's Dockerfile, before any USER line, and leave the app's CMD alone. The scheduler service builds the same image and overrides command, so app keeps running the web server.

services:
  app:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
 
  scheduler:
    build: .
    env_file: .env
    command: ["supercronic", "/app/crontab"]
    restart: unless-stopped

Both services read the same env_file, so jobs get the app's config, and docker compose logs -f scheduler follows the runs. Keep scheduler at one replica. If your Dockerfile sets an ENTRYPOINT, Compose passes command to it as arguments, so give the scheduler entrypoint: ["supercronic", "/app/crontab"] instead.

A tiny cron container that calls your app over HTTP

If the work already sits behind an internal endpoint, the scheduler doesn't need your code. A small Alpine image with BusyBox crond can call the app over the Compose network. Plain alpine has no curl, so save this as Dockerfile.cron next to a crontab.http file:

FROM alpine:3.24
RUN apk add --no-cache curl
COPY crontab.http /etc/crontabs/root
CMD ["crond", "-f", "-d", "8"]
services:
  app:
    build: .
    environment:
      CRON_SECRET: ${CRON_SECRET}
 
  cron:
    build:
      context: .
      dockerfile: Dockerfile.cron
    environment:
      CRON_SECRET: ${CRON_SECRET}
    depends_on:
      - app
    restart: unless-stopped
0 2 * * * curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" http://app:8000/internal/nightly

BusyBox crond passes its environment through, so $CRON_SECRET is set when the job runs. This is Pattern 4's shape with the clock still on your host.

Ofelia: a Docker job scheduler driven by labels

Ofelia is "a modern and low footprint job scheduler for docker environments," configured through labels. job-exec runs a command in a running container, and job-run starts a new container from an image.

services:
  ofelia:
    image: mcuadros/ofelia:0.3.22
    command: daemon --docker
    environment:
      TZ: Europe/Berlin
    depends_on:
      - app
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
 
  app:
    build: .
    labels:
      ofelia.enabled: "true"
      ofelia.job-exec.nightly.schedule: "0 0 2 * * *"
      ofelia.job-exec.nightly.command: "/app/nightly.sh"
      ofelia.job-exec.nightly.no-overlap: "true"

Watch the schedule. On Ofelia 0.3.x, which both latest and the pinned 0.3.22 tag ship as of October 2026, the first field is seconds. A five-field 0 2 * * * raises no error: Ofelia reads it as second 0, minute 2, every hour, so the job fires hourly at :02 instead of at 02:00.

Use six fields like 0 0 2 * * *, or descriptors such as @daily. The README's five-field examples describe the 0.4.x betas, where seconds are optional, so pin the tag. Schedules follow the Ofelia container's clock, which is UTC unless you set TZ as above (the v0.3.22 image includes tzdata).

Label config requires the Docker socket, which is effectively root on the host, and :ro doesn't change that: the API still accepts every command over a read-only socket mount. Ofelia honors DOCKER_HOST if you want a socket proxy in between. no-overlap skips a run while the last one is still going, and its mail, save, and slack drivers can report results.

The host-cron shortcut: docker compose run --rm

This isn't a fifth pattern, just the host's crontab starting a one-off container for each run.

0 2 * * * cd /srv/myapp && docker compose run --rm -T app /app/nightly.sh >> /var/log/nightly.log 2>&1

Per the compose run reference, --rm removes the container when it finishes, even if the service has a restart policy, and run doesn't publish the service's ports. -T skips the terminal that cron doesn't have. The catch is that scheduling now lives on one VM, outside your repo, and breaks when you add a second node.

Pattern 3: let the orchestrator own the schedule

If you already run an orchestrator, use its scheduler, so schedules live next to your deployment config and no image needs a cron daemon.

Kubernetes CronJob

CronJob has been stable since Kubernetes v1.21, and .spec.timeZone since v1.27. Without timeZone, the kube-controller-manager reads schedules in its own local zone, and putting CRON_TZ or TZ inside .spec.schedule is rejected.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 2 * * *"
  timeZone: "Europe/Berlin"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: nightly-report
              image: ghcr.io/acme/app:1.4.2
              command: ["/app/nightly.sh"]

concurrencyPolicy defaults to Allow; Forbid skips a run while the last one is still going, and Replace swaps it for the new one. If you set startingDeadlineSeconds, keep it at 10 or more, since the controller checks every 10 seconds.

The docs are upfront that "two Jobs might be created, or no Job might be created," so "the Jobs that you define should be idempotent," which means safe to run twice with the same result.

Docker Swarm: swarm-cronjob

Swarm has jobs (docker service create --mode replicated-job or global-job) that run to completion and aren't run again. There's no schedule flag, though (service create reference). swarm-cronjob is a maintained scheduler that fills the gap by reading cron schedules from service labels.

services:
  swarm-cronjob:
    image: crazymax/swarm-cronjob:1.16.0
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"
    environment:
      - "TZ=Europe/Berlin"
      - "LOG_LEVEL=info"
      - "LOG_JSON=false"
    deploy:
      placement:
        constraints:
          - node.role == manager
 
  nightly:
    image: ghcr.io/acme/app:1.4.2
    command: ["/app/nightly.sh"]
    deploy:
      mode: replicated
      replicas: 0
      restart_policy:
        condition: none
      labels:
        - "swarm.cronjob.enable=true"
        - "swarm.cronjob.schedule=0 2 * * *"
        - "swarm.cronjob.skip-running=true"

The swarm-cronjob service comes from its Docker install page, pinned to v1.16.0 (the latest release as of October 2026), and needs the Docker socket on a manager node. The job service follows the get-started rules: replicas: 0 so it doesn't run on deploy, and restart_policy.condition: none so the task doesn't restart on its own.

The docs deploy two stacks, but this combined file works as one: docker stack deploy -c stack.yml app. A leading seconds field is optional, so 0 2 * * * means 02:00 in the scheduler's TZ. Ofelia's job-service-run is the alternative.

Pattern 4: call your container from external HTTP cron

Keep the image single-purpose: expose an authenticated endpoint and let a scheduler outside the host call it. There's no cron in the image and no socket mount, deploys never touch schedule state, and the setup looks the same on a VPS, Railway, Fly.io, Render, or Cloud Run.

services:
  api:
    build: .
    environment:
      CRON_SECRET: ${CRON_SECRET}
    ports:
      - "8000:8000"
import express from "express";
import { runNightly } from "./jobs/nightly.js";
 
const app = express();
 
app.post("/internal/nightly", async (req, res) => {
  const secret = process.env.CRON_SECRET;
  if (!secret || req.get("authorization") !== `Bearer ${secret}`) {
    return res.sendStatus(401);
  }
  try {
    await runNightly();
    res.sendStatus(204);
  } catch (err) {
    console.error(err);
    res.sendStatus(500);
  }
});
 
app.listen(8000);

Return 2xx on success and a 5xx when the work fails, so the default retry policy picks it up. A 4xx, like the 401 above, counts as a failure but doesn't retry unless you opt in, which Pro and Ultra can do. Other stacks: Node.js, PHP, Laravel, Django.

In Crontap, the schedule looks like this:

  • URL https://api.example.com/internal/nightly, method POST.
  • Header Authorization: Bearer <CRON_SECRET>.
  • Cron 0 2 * * * with an IANA timezone such as Europe/Berlin, set per schedule.
  • Perform test before you save it, so you see the real status code from your endpoint.

Crontap calls your endpoint from the internet, so it needs a public URL: your app behind auth, or a tunnel or ingress for internal services. Use HTTPS so the bearer token never travels in plain text.

New schedules retry 5xx responses, timeouts, and network failures by default, with exponential backoff. Every plan can turn retries on or off, and Pro and Ultra can tune one to five retries, the starting delay, and whether 429 or other 4xx responses retry (how the retries work). Retries are bounded and happen inside the same run, not from a durable queue, and a recurring run stops retrying shortly before its next scheduled run.

History records each attempt's status, status code, duration, and size. Once the attempts are exhausted, the run counts as failed, and your notification thresholds and routing decide where an alert goes: email on every plan, and Slack, Discord, and Telegram on paid plans. The free Starter plan includes two schedules, monitors, or heartbeats combined, with schedules hourly or slower, and paid plans can run them every minute.

If a run can outlast its interval, or a retry could repeat work a timed-out attempt already finished, guard it in the app with a lock row, a Postgres advisory lock, or flock. If the job outlasts your proxy's request timeout, start it in the background, return 202, and report the result through a heartbeat.

If the job already sits behind an endpoint in your Compose stack, paste its URL, add the bearer header, pick a timezone, and click Perform test. Schedule your first job.

The four patterns side by side

1. Cron in the container2. Compose scheduler service3. Orchestrator (CronJob, swarm-cronjob)4. External HTTP cron (Crontap)
Where the clock livesInside the imageA neighbor containerCluster control plane or a manager serviceOutside your host
Env vars in jobsDebian cron: KEY=VALUE lines or /etc/environment; BusyBox and supercronic: inheritedSame as the tool used; Ofelia job-exec runs in the target containerFrom the pod or service specYour app's normal env
Output in docker logsDebian: redirect to /proc/1/fd/1; crond with -d 8 and supercronic: yesYes with crond -d 8 or supercronic; Ofelia: its own logs and driverskubectl logs or service logsApp logs plus Crontap run history
Overlap controlsupercronic waits by default; flock otherwisesupercronic by default; Ofelia no-overlapconcurrencyPolicy: Forbid; skip-runningGuard it in the app
TimezoneDebian cron: /etc/localtime; Alpine and supercronic: TZ plus tzdata; supercronic CRON_TZSame as Pattern 1; Ofelia: TZ on its container.spec.timeZone (Kubernetes 1.27+); TZ on swarm-cronjobPer-schedule IANA timezone
ScalingRuns once per replicaKeep the scheduler at 1 replicaOne Job per schedule (keep it idempotent)One request per attempt; retries can repeat it, so make it idempotent
Extra moving partsNoneOne container; Ofelia needs the Docker socketA cluster; swarm-cronjob also needs the Docker socket on a managerA public endpoint with auth (HTTPS recommended)
Failure alerts built inNone by default (MAILTO needs a mail server; supercronic can report to Sentry)Ofelia mail and slack drivers; otherwise noneEvents and Job status; alerting is yoursEmail on every plan; Slack, Discord, Telegram on paid plans; thresholds you set

Cron job not running in Docker? Check these in order

Container cron tends to fail silently, so work down the list. For causes that apply outside containers too, see cron job not running? and the cron troubleshooting hub.

  1. Is the scheduler in the foreground? Run cron -f, crond -f, or supercronic, with exec if a script starts it, or the container exits. docker top <container> shows what's running from the host, even on debian:trixie-slim, which has no ps.
  2. Is the crontab where the daemon reads it? Debian: /etc/cron.d/<name> with a user field, owned by root, not group- or other-writable, no dots. Alpine: /etc/crontabs/root, owned by root, no user field. Check with docker exec <container> cat on the file.
  3. Trailing newline and line endings. A missing final newline breaks the last entry, and Windows CRLF line endings are a commonly reported cause too. Convert with dos2unix or sed -i 's/\r$//' crontab before you build.
  4. Missing env vars or output. Debian cron strips env and mails output: write /etc/environment in the entrypoint and redirect jobs with >> /proc/1/fd/1 2>&1, or switch to supercronic. On Alpine, start crond with -d 8.
  5. Wrong hour. Run docker exec <container> date. Alpine without tzdata is UTC. Debian cron uses /etc/localtime for a single timezone, and a TZ= line in a crontab only changes the commands' environment, not when they fire.
  6. Percent signs and inline comments. An unescaped % becomes a newline, so write \%. Cron doesn't treat a trailing # as a comment: after a command it goes to the shell, and after a KEY=VALUE line it becomes part of the value. Keep comments on their own lines.
  7. Test it on demand. docker exec <container> /app/nightly.sh rules out the script itself. Then set the schedule to * * * * * for a minute to test the cron side. supercronic -test validates syntax, and the cron job debugger checks the expression.

Monitoring Docker cron jobs: HEALTHCHECK is not enough

A Dockerfile HEALTHCHECK runs a probe command every 30 seconds by default and reads its exit status (Dockerfile reference). That answers "is this container working right now," not "did the 02:00 job run," and a healthy scheduler container can still skip every job because of a broken crontab or the wrong timezone.

What answers it is a dead man's switch: the job pings a URL when it finishes, and you get alerted when the ping doesn't arrive within the period plus a grace window (heartbeat vs uptime monitor).

Crontap heartbeats give each job a secret URL like https://ping.crontap.com/<uuid>. Append /fail to report a failure, which alerts right away, add an optional ?msg=, and send GET, HEAD, or POST. You get alerts on missed, failed, and recovered runs, with 90 days of history.

There's no /start signal or duration tracking. The clock starts on the first accepted ping, so run the job or send a test ping once after creating the heartbeat.

The simplest version pings on success only:

0 2 * * * /app/nightly.sh && curl -fsS -m 10 --retry 3 -o /dev/null "$HEARTBEAT_URL"

A small wrapper reports failures too, so you hear about them without waiting out the grace window. Save it as /app/run-nightly.sh, copy it with --chmod=755, and point the crontab line at it instead of nightly.sh:

#!/bin/sh
if /app/nightly.sh; then
  curl -fsS -m 10 --retry 3 -o /dev/null "$HEARTBEAT_URL"
else
  status=$?
  curl -fsS -m 10 --retry 3 -o /dev/null "$HEARTBEAT_URL/fail?msg=nightly-failed"
  exit "$status"
fi

Both depend on an honest exit code. If nightly.sh is a bash script with a pipeline such as pg_dump | gzip, start it with set -euo pipefail, or a failed dump still exits 0. On Alpine, BusyBox wget -q -O /dev/null can replace curl. With Debian cron, HEARTBEAT_URL only reaches the job through /etc/environment or a crontab line.

With Pattern 4 you may not need a heartbeat at all, since the schedule's run history and failure notifications already show whether each run happened and succeeded. The heartbeat monitoring page has more setups.

FAQ

Docker cron job questions

How do I run a cron job inside a Docker container?
Copy a crontab into the image and run the scheduler in the foreground as the container's main process. Use cron -f on Debian or Ubuntu, crond -f -d 8 on Alpine, or supercronic with the path to your crontab on any base image.
Does Docker Compose have a cron or schedule option?
No. The Compose services spec has no schedule key, so you add a scheduler service running supercronic or crond, use Ofelia labels, call docker compose run --rm from the host's crontab, or have an external HTTP cron call an endpoint in your stack.
Why can't my cron job see my Docker environment variables?
Debian cron gives jobs only a few variables, such as SHELL, LOGNAME, and HOME. Write printenv > /etc/environment in the entrypoint, which Debian's cron loads through PAM, or switch to supercronic. BusyBox crond on Alpine passes its environment through to jobs.
How do I see cron output in docker logs?
docker logs shows the main process's stdout and stderr. Redirect Debian cron jobs with >> /proc/1/fd/1 2>&1, start Alpine's crond with -d 8, or use supercronic, which logs job output directly.
Where does the crontab go in a Docker container?
On Debian or Ubuntu, put a system file in /etc/cron.d with a user field, root ownership, and no dots in the name, or install a user crontab with the crontab command. On Alpine, use /etc/crontabs/root, owned by root, without a user field. Supercronic reads whatever path you pass it.
How do I set the timezone for cron in Docker?
On Debian or Ubuntu cron, link /etc/localtime to your zone, for example /usr/share/zoneinfo/Europe/Berlin, and install tzdata first on Ubuntu, whose base image lacks it. On Alpine and with supercronic, set TZ and install tzdata where it is missing, since the Alpine base image has none and stays on UTC. Supercronic also accepts a CRON_TZ line, Ofelia follows TZ on its own container, and Kubernetes CronJob has .spec.timeZone.
How do I run cron jobs in Docker Swarm?
Swarm jobs run to completion but have no schedule. Run swarm-cronjob on a manager node and add its labels to a service with replicas set to 0 and a restart policy condition of none, or use Ofelia's job-service-run.
How do I stop overlapping cron runs in Docker?
Supercronic waits for a job to finish by default, Ofelia has no-overlap, Kubernetes has concurrencyPolicy Forbid, and swarm-cronjob has skip-running. Anywhere else, wrap the command in flock -n with a lock file.
How do I monitor cron jobs running in Docker?
A HEALTHCHECK only shows the container is working right now, not that a scheduled job ran. Have the job ping a heartbeat URL on success and its /fail URL on error. A /fail ping raises a failed alert right away, and a missing ping raises a missed alert once the period plus grace has passed.

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Product Updates

Introducing AI Integrations

Transform a schedule's HTTP response with a plain-English prompt, return text or JSON, and forward it to Slack, Make, n8n, or your own endpoint. Test on any tier; saving is a Pro feature.

Alternatives

Vercel cron jobs: the Hobby once-per-day limit and how to beat it

Vercel Cron caps Hobby at one run per day, only guarantees timing within the hour, is UTC only, and ties every schedule change to a redeploy. Here is the external cron pattern teams use to ship per-minute, timezone-aware schedules and one dashboard across projects without paying $20/mo per user for Pro.