Running cron jobs in Docker sounds straightforward until you try it. You add a line to crontab, the job runs on schedule, and if something goes wrong you get an email. At least, that's how it works on a regular server. Move that same job into a container, and three things break at once: your environment variables disappear, logs go to a place nobody checks, and failed jobs don't notify anyone. Your container just sits there, looking healthy on the outside while the nightly database backup hasn't run in a week.
This guide covers why cron behaves differently inside containers, four ways to set it up properly, and how to make sure you actually find out when something stops working.
Why cron jobs break inside Docker containers
On a normal Linux server, cron has access to everything: environment variables, the mail system, syslog. Inside a container, most of that is gone. Here are the five problems you'll run into.
Environment variables vanish
Here's the one that gets everyone. You set DATABASE_URL and API_KEY in your docker-compose.yml, your app reads them fine, but when cron runs your script those variables are empty. Cron starts each job with a stripped-down environment: just HOME, LOGNAME, PATH, and SHELL. Everything you passed through docker run -e or ENV in the Dockerfile gets dropped.
Your script works perfectly when you run it manually with docker exec. But when cron triggers it, the database connection fails because the credentials aren't there. I spent a few hours on this one before figuring out what was happening.
Logs go nowhere
Cron traditionally sends output to syslog. Containers don't run syslog. So when your cron job prints errors, they vanish. Run docker logs mycontainer and you see nothing from cron, because cron writes to a logging facility that doesn't exist in the container.
Most people work around this by appending >> /proc/1/fd/1 2>&1 to each cron command, which redirects output to the container's main process stdout. It works, but it's fragile and ugly. There are better options.
No email alerts
On a normal server, cron emails you when a job produces output or fails. Containers don't have a mail transfer agent. So cron's built-in notification system does nothing. Your job fails, cron tries to send an email, the email goes nowhere. Silent failure.
Zombie processes
When cron runs as the main process in the container (called PID 1, which is the first process Docker starts), it doesn't properly clean up child processes. Dead processes pile up as zombies. For a job that runs every minute, you can accumulate hundreds of zombies within a day. They don't consume much memory, but they're a sign something isn't right, and eventually you hit the process limit.
Timezone surprises
Containers default to UTC. If you schedule a job for 0 9 * * * expecting 9 AM in your local timezone, it runs at 9 AM UTC instead. That's 4 AM Eastern, 2 AM Pacific. Not a bug, just a default that catches people off guard. Ran into this deploying a reporting job and couldn't figure out why results were six hours stale.
Approach 1: host cron with docker exec
The simplest approach. Don't run cron inside the container at all. Keep cron on the host machine and use docker exec to run commands inside a running container.
# Host crontab (crontab -e on the server)
*/5 * * * * docker exec app-container /app/backup.sh >> /var/log/backup.log 2>&1
That sidesteps every single problem from the previous section. The host's cron handles scheduling, environment variables are available inside the container normally, and logs go wherever you redirect them.
The downside: your schedule lives on the host, not in the container. You can't version-control it with your Dockerfile, and if you move to a different server or a container platform like Kubernetes, the setup doesn't travel with the app. For a single server running a few containers, though, this is hard to beat.
Approach 2: traditional cron inside the container
Install cron in the container and run it as the main process. Most Docker cron tutorials show this approach.
Create a crontab file:
# crontab file
*/5 * * * * /app/backup.sh >> /proc/1/fd/1 2>&1
Build a Dockerfile around it:
FROM python:3.12-slim
RUN apt-get update && apt-get install -y cron curl \
&& rm -rf /var/lib/apt/lists/*
COPY backup.sh /app/backup.sh
RUN chmod +x /app/backup.sh
COPY crontab /etc/cron.d/app-cron
RUN chmod 0644 /etc/cron.d/app-cron && crontab /etc/cron.d/app-cron
CMD ["cron", "-f"]
The -f flag keeps cron in the foreground so Docker doesn't think the container exited.
If you're using an Alpine-based image, the command is different: crond -f -l 2 instead of cron -f. Alpine uses BusyBox cron, which has its own quirks.
Now the environment variable problem. You need to dump the runtime environment into a file that cron can source. Add an entrypoint script:
#!/bin/bash
# entrypoint.sh
# Save current environment for cron
# (filtering out no_proxy to avoid format issues in /etc/environment)
printenv | grep -v "no_proxy" >> /etc/environment
# Start cron in the foreground
cron -f
Update the Dockerfile:
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
And update your cron command to source the environment first:
*/5 * * * * . /etc/environment; /app/backup.sh >> /proc/1/fd/1 2>&1
It works. But it's a lot of moving parts for something that should be simple. Three extra files, a workaround for environment variables, fragile log redirection. If you're starting a new project, consider Supercronic instead.
Approach 3: Supercronic (built for containers)
Supercronic is a cron replacement designed from scratch for containers. It fixes every problem with traditional cron in Docker:
- Runs in the foreground (no zombie processes)
- Picks up your environment variables automatically
- Logs everything to stderr, which Docker captures automatically (no redirect hacks)
- Shuts down cleanly when the container stops (handles the stop signal properly)
- Uses the same cron expression syntax you already know
Here's the same backup job, but cleaner:
FROM python:3.12-slim
# Install supercronic
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.33/supercronic-linux-amd64
RUN curl -fsSL "${SUPERCRONIC_URL}" -o /usr/local/bin/supercronic \
&& chmod +x /usr/local/bin/supercronic
COPY backup.sh /app/backup.sh
RUN chmod +x /app/backup.sh
COPY crontab /app/crontab
CMD ["supercronic", "/app/crontab"]
And the crontab file:
*/5 * * * * /app/backup.sh
No /proc/1/fd/1 redirects. No entrypoint script to dump environment variables. No zombie process worries. Output from /app/backup.sh shows up directly in docker logs.
One thing to keep in mind: Supercronic is a standalone binary (around 10MB). For minimal Alpine images where size matters, that adds up. For most production containers based on Debian or Ubuntu, 10MB is nothing. Supercronic also runs fine as a non-root user, unlike traditional cron which usually needs root. If your Dockerfile already sets USER appuser, Supercronic works without changes.
Approach 4: Ofelia and label-based scheduling
If you're running multiple containers with Docker Compose, Ofelia takes a different approach. Instead of installing cron in each container, Ofelia runs as a separate container that reads schedule configuration from Docker labels on your other containers.
# docker-compose.yml
services:
app:
image: myapp:latest
labels:
ofelia.enabled: "true"
ofelia.job-exec.backup.schedule: "0 */6 * * *"
ofelia.job-exec.backup.command: "/app/backup.sh"
ofelia:
image: mcuadros/ofelia:latest
command: daemon --docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
Ofelia uses docker exec under the hood, so your jobs run inside the app container with full access to environment variables and the application code. You define Docker Compose cron job schedules in labels, which means they're version-controlled alongside your app.
One trade-off: Ofelia needs access to the Docker socket, which is a security consideration. It can execute commands inside any container on the host. For trusted environments this is fine. For shared hosts or multi-tenant setups, you'd want to think about that.
Quick comparison: which approach fits
| Host cron | Cron in container | Supercronic | Ofelia | |
|---|---|---|---|---|
| Setup effort | Low | Medium | Low | Low |
| Env variables | Just work | Workaround needed | Just work | Just work |
| Logs in docker logs | No (host logs) | With redirect hack | Yes | Yes |
| Portable | No (tied to host) | Yes | Yes | Yes (Compose only) |
| Multi-container | Manual per container | One cron per container | One per container | One scheduler for all |
| Graceful shutdown | N/A | No | Yes | Yes |
| Best for | Single server, few containers | Legacy setups | Most new projects | Docker Compose stacks |
For most new projects, Supercronic is the best starting point. If you're already running Docker Compose with multiple services, Ofelia saves you from installing a scheduler in each container. And if you're on a single server and want zero complexity, host cron with docker exec gets the job done.
Monitoring Docker cron jobs: knowing when they break
Setting up cron in Docker is the easy part. Knowing when it stops working is harder. Containers make silent failures the default, not the exception. Your container looks healthy, passes health checks, shows green in your dashboard. But the actual cron job inside? It might have stopped running days ago.
Consider what can go wrong without you noticing:
- The backup script runs but fails to upload because credentials expired. Exit code 0.
- The container ran out of memory during data processing. Docker Swarm restarted it, but the cron schedule reset.
- Someone deployed a new image that accidentally changed the crontab. No error, the old schedule just disappeared.
- The container is running, but the cron process inside it crashed silently.
Docker's built-in HEALTHCHECK tells you if the container is alive. It doesn't tell you if the cron job inside it actually did its work. Those are two different questions.
Heartbeat monitoring (the dead man's switch)
The simplest way to monitor a containerized cron job: add a ping at the end of the command. If the job finishes successfully, it hits a URL. If the ping doesn't arrive on time, you get an alert.
This pattern is called a dead man's switch. It catches every type of failure: the job didn't start, the job started but failed, the container crashed, the cron process died.
With Supercronic, the crontab looks like this:
0 3 * * * /app/backup.sh && curl -fsS https://watchcron.com/ping/your-uuid > /dev/null
The && matters. The ping only fires if backup.sh exits with code 0. If the script fails, no ping, and the monitoring service notices the silence.
With traditional cron, wrap the ping in the redirect chain:
0 3 * * * . /etc/environment; /app/backup.sh && curl -fsS $WATCHCRON_PING_URL > /dev/null 2>> /proc/1/fd/2
Pass the ping URL as an environment variable (WATCHCRON_PING_URL) so you can change it without rebuilding the image.
What to monitor beyond "did it run"
A heartbeat ping answers one question: did the job finish? For production systems, you usually want to know more.
- How long did it take? A backup that took 2 minutes last month and takes 15 minutes now might be about to time out.
- Did it actually succeed? Some jobs always run but occasionally exit with errors that nobody checks.
- What did it produce? Parsing the output catches problems that exit codes miss.
Cron job monitoring tools like WatchCron track all three. You set a grace period (how long to wait before considering a job late), and the service alerts you through Slack, email, Telegram, or whatever channel you've set up. For a job that runs every five minutes, a 2-minute grace period catches problems within 7 minutes of the first missed run.
A complete Docker Compose cron job example with monitoring
Here's a realistic setup: a Python app with a database backup job running every 6 hours, using Supercronic, with docker cron monitoring wired in.
# docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
cron:
build: .
entrypoint: ["supercronic", "/app/crontab"]
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
- WATCHCRON_PING_URL=https://watchcron.com/ping/abc-123
depends_on:
- db
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
The crontab file:
# /app/crontab
0 */6 * * * /app/backup.sh && curl -fsS "$WATCHCRON_PING_URL" > /dev/null
And the backup script itself:
#!/bin/bash
# /app/backup.sh
set -euo pipefail
TIMESTAMP=$(date +%F-%H%M)
BACKUP_FILE="/backups/myapp-${TIMESTAMP}.sql.gz"
pg_dump "$DATABASE_URL" | gzip > "$BACKUP_FILE"
# Verify the backup file exists and isn't empty
if [ ! -s "$BACKUP_FILE" ]; then
echo "Backup file is empty or missing: $BACKUP_FILE" >&2
exit 1
fi
echo "Backup completed: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
A few things to notice. The set -euo pipefail line at the top tells the script to stop immediately if anything goes wrong. Without it, a command like pg_dump | gzip could silently fail on the first half (the database dump) while the second half (compression) still runs, leaving you with an empty backup file that looks fine. The check at the end catches exactly that scenario.
The cron container shares the same image as the app but uses a different entrypoint. You build once, run twice with different commands. The backup script has access to all the same dependencies and configuration.
Troubleshooting Docker cron problems
When things go wrong, here's where to look.
"My cron job doesn't run at all." Check that the cron process is actually running inside the container: docker exec mycontainer ps aux | grep cron. If you're using traditional cron, verify the crontab is loaded: docker exec mycontainer crontab -l. A common mistake is building the crontab file with Windows line endings (CRLF). Cron silently ignores lines with \r at the end. Convert with dos2unix or add RUN sed -i 's/\r$//' /etc/cron.d/app-cron to your Dockerfile.
"The job runs but can't connect to the database." Environment variables again. Run docker exec mycontainer env to see what's available, then compare with what cron sees. If you're sourcing /etc/environment, make sure the entrypoint script wrote it before cron started.
"I see no output in docker logs." If using traditional cron, add the redirect: >> /proc/1/fd/1 2>&1. With Supercronic, output should appear automatically. Check that Supercronic is actually running as the main container process (not behind an entrypoint that swallows its output).
"Jobs run at the wrong time." Check the container's timezone: docker exec mycontainer date. If it shows UTC and you need a different timezone, set the TZ environment variable: TZ=America/New_York. Or just schedule everything in UTC and note the conversion in a comment above the cron line. UTC avoids daylight saving time confusion entirely.
"The container restarts and cron stops." If your cron schedule is set up at runtime (not baked into the image), a container restart wipes it. Always include the crontab in the Docker image, either as a COPY in the Dockerfile or mounted as a volume.
Beyond Docker: other ways to run scheduled tasks
If you're running scheduled work on Kubernetes, use Kubernetes CronJobs instead of in-container cron. They solve the problem at a different level: the cluster creates a fresh pod for each run, handles retries, and tracks execution history natively.
For jobs that don't need a server at all, check our guide on running cron jobs without a server. GitHub Actions, cloud functions, and cloud cron services handle Docker scheduled tasks without you managing containers at all.
If you're working with WordPress in Docker, WP-Cron has its own quirks on top of the container issues. Our WP-Cron replacement guide covers that specifically.
Running cron jobs in Docker doesn't have to be complicated. Pick the approach that fits your setup, add a heartbeat ping, and move on. The goal isn't a perfect cron architecture. It's knowing that your backup ran last night.