July 31, 2026

Docker Compose cron jobs with a web UI: 4 approaches

Here is the line most people end up with. It lives in the host crontab, on the box that runs the stack:

0 3 * * * cd /srv/acme && docker compose exec -T db pg_dump -U postgres acme | gzip > /backups/db.sql.gz

It works. Right up until it doesn’t.

One night the db container is restarting when 3 AM comes around. docker compose exec exits 1, cron dutifully mails the error to the local user, there is no MTA on the box, and the mail goes into a spool file nobody has ever opened. Your backup directory keeps the file from the night before, and ls -la looks fine at a glance because there is a file there. You find out eleven days later, for the usual reason.

The scheduling was never the hard part. Cron is very good at firing things at 3 AM. The hard part is that a container stack has no obvious place to put “what happened when it fired.”

The four options, and what each one costs

ApproachSchedule in your repoRun historyPer-run outputFailure alertsWeb UI
crond in the app image
Host crontab
Cron sidecar container
Ofelia labels

The five columns are the reasons this article exists. Every approach schedules fine and then drops most of the rest on the floor. The detail on each:

crond inside the app image. Now your container runs two processes, so you need an init system or a supervisor to keep PID 1 honest. Job output goes to the container’s mail spool (which doesn’t exist) or to a file inside a layer that vanishes on the next deploy. Rebuilding the image to change a schedule is a rough trade.

Host crontab calling into the stack. The example above. Simple and it works, but the schedule now lives outside the compose file, so it doesn’t come along when you move hosts, and it’s invisible to anyone reading the repo. Silent on failure by default.

A cron sidecar container with a crontab bind-mounted in. Better (the schedule is in the repo now), but the output problem is identical. You still have to go read docker logs and hope the lines you want haven’t rotated away.

Ofelia labels. Genuinely nice if your whole world is compose. Three labels on a service and it runs. But there is no run history, no retries, no captured output per firing. When someone asks “did the backup run on the 14th,” you can’t answer.

All four schedule fine. None of them answer the question you actually have.

What “with a web UI” should mean

Not a dashboard for the sake of a dashboard. Three concrete things:

  1. Every firing is a row: start time, duration, exit code, and the stdout and stderr it produced.
  2. A button that runs the job right now, so you stop SSH-ing in to test a cron expression.
  3. Something that tells you when it broke, without you asking.

This is what RunWisp does. It’s a single Go binary (a scheduler, a supervisor, embedded SQLite, and the dashboard, all in one image) and it drops into a compose file like any other service. Here’s the stack from this article, running:

localhost:9477
five jobs, one of them unhappy
RunWisp dashboard showing five scheduled jobs (db-backup, db-vacuum, healthcheck, prune-uploads, report-export) grouped in a sidebar, with stat cards for healthy tasks, needs attention, running now, and recent success rate

The setup

Say you have a boring three-service stack: nginx, Postgres, and whatever your app is.

Add a fourth service. RunWisp needs the Docker CLI to reach your other containers, and the official image is a deliberately tiny Alpine (busybox plus the binary, about 14 MB compressed), so build a one-line image on top of it:

# docker/runwisp.Dockerfile
FROM runwisp/runwisp:latest
RUN apk add --no-cache docker-cli docker-cli-compose curl

Then the service itself:

# docker-compose.yml
services:
    runwisp:
        build:
            dockerfile: docker/runwisp.Dockerfile
        container_name: acme-runwisp
        restart: unless-stopped
        ports:
            - "9477:9477"
        environment:
            - RUNWISP_PASSWORD=change-me
            - RUNWISP_SOCKET=/var/lib/runwisp/runwisp.sock
        command: runwisp daemon -c /etc/runwisp/runwisp.toml --data /var/lib/runwisp --host 0.0.0.0
        volumes:
            - ./runwisp.toml:/etc/runwisp/runwisp.toml:ro
            - /var/run/docker.sock:/var/run/docker.sock
            - backups:/backups
            - runwisp-data:/var/lib/runwisp

volumes:
    backups:
    runwisp-data:

A few of those lines are load-bearing, so:

command: spells out the config path, the data directory, and the bind address instead of relying on the image defaults. Being explicit here is version-proof and it makes the compose file readable six months from now, which is the actual audience.

--host 0.0.0.0 is required or the port mapping reaches nothing. The daemon binds 127.0.0.1 by default, and inside a container that means “nobody.”

RUNWISP_SOCKET points at a path on the persistent volume. That’s what the container’s own HEALTHCHECK uses to ask the daemon if it’s alive, and it’s what lets you run runwisp CLI commands inside the container later.

runwisp-data is a named volume. That’s where SQLite and the per-run logs live, so if you skip it your history dies with the container.

Then the jobs, in a file next to your compose file:

# runwisp.toml
[daemon]
tls = "off"

[scheduler]
timezone = "Europe/Bratislava"

[defaults]
keep_runs = 50

[tasks.db-backup]
group       = "Backups"
description = "Nightly pg_dump of the acme database"
cron        = "0 3 * * *"
timeout     = "30m"
on_overlap  = "skip"
run = """
docker exec acme-db pg_dump -U postgres acme | gzip > /backups/acme-$(date +%F).sql.gz
ls -lh /backups | tail -3
"""

[tasks.healthcheck]
group       = "Health"
description = "Is the site answering?"
cron        = "*/5 * * * *"
timeout     = "30s"
run         = "curl -fsS -o /dev/null -w 'http %{http_code} in %{time_total}s\n' http://web/"

The docker exec acme-db in the backup task assumes your Postgres service has container_name: acme-db in your compose file. Pin container names explicitly — compose-generated names (acme-db-1) change when you scale or recreate, and your tasks will break.

docker compose up -d, and:

~/srv/acme
it tells you what it's doing
Terminal showing docker compose up -d creating four containers, then docker compose logs runwisp printing a security warning about binding to 0.0.0.0 in cleartext HTTP, followed by INFO lines for config path, instance fingerprint, scheduler timezone, and 'ready, listening url=http://localhost:9477'

That security banner is not decoration. You just put a control panel that executes shell commands on 0.0.0.0. Keep the port on a private network, or put a reverse proxy in front of it, and read the security notes before you do anything clever.

If you already have a crontab

Don’t retype it. Point the importer at it and it hands you TOML:

docker run --rm -v ./crontab.txt:/tmp/c:ro runwisp/runwisp:latest runwisp import cron /tmp/c

It also notices things. Feed it a crontab with MAILTO="" at the top and it tells you that RunWisp doesn’t email job output and that you probably want a notifier instead. There’s a supervisord importer too.

Reaching your other containers

This is the part every other article skips, and it’s the whole game. RunWisp runs /bin/sh -c inside its own container. If your run = calls pg_dump, then pg_dump has to be reachable from there. Two ways to do that.

Exec into a container that’s already running. This is what you want for anything that belongs to a live app (php artisan, manage.py, psql, a rake task):

[tasks.db-vacuum]
cron = "30 4 * * 0"
run  = "docker exec acme-db psql -U postgres -d acme -c 'VACUUM ANALYZE'"

Two footguns here. Never pass -it (there’s no TTY, so it just errors out), and pin container_name: in your compose file, because compose-generated names change out from under you the moment someone scales a service. Note that plain docker exec has no -T flag (that one belongs to docker compose exec), which is an easy five minutes to lose.

Run a compose service as a one-shot container. If the job is already defined as a service in your compose file (a backup or migrate image that runs and exits), point at it directly and skip the shell entirely:

[tasks.nightly-backup]
cron            = "0 3 * * *"
compose_file    = "/etc/runwisp/docker-compose.yml"
compose_service = "backup"

That runs docker compose run --rm, so you get a fresh container per firing and nothing left behind when a timeout kills one. Two things to know. The compose file has to be mounted into the RunWisp container (paths resolve inside it, not on the host), and the run gets its own Compose project, so the container comes up on a fresh network where db does not resolve. Fine for a job that’s self-contained. Not fine for one that needs to talk to the rest of your stack.

When it does need the stack, name the project yourself and shell out:

[tasks.nightly-backup]
cron = "0 3 * * *"
run  = "docker compose -f /etc/runwisp/docker-compose.yml -p acme run --rm backup"

-p acme is the Compose project name (the one in docker compose ls, which defaults to the directory holding your compose file). Get it wrong and you’ll spend a while wondering why a hostname that works everywhere else suddenly doesn’t resolve.

If you write your own docker run in a task, add --rm yourself. Without it, a timeout leaves a stopped container behind every single night, and those pile up quietly until a disk alert wakes you up.

Either way you need /var/run/docker.sock mounted, and that deserves one honest sentence: the Docker socket is effectively root on the host. A task can start a privileged container. Your TOML is written by you, so this is usually fine, but it’s a decision and not a detail.

The part you actually wanted

Job fails. Here’s what that looks like instead of an unread mail spool:

localhost:9477/tasks/report-export
exit 6, retry #2, and the line that caused it
RunWisp run detail for a failed report-export job: status FAILED, exit code 6, ran for 90ms, marked as retry number 2, with console output showing 'curl: (6) Could not resolve host: reports.internal' and a sidebar listing six previous failed runs

Exit 6, ran for 90ms, this was retry #2, and the reason is right there: curl: (6) Could not resolve host: reports.internal. Somebody renamed a service. You knew that in about four seconds instead of on Monday.

Retries and alerts are per-task:

[[notifier]]
id          = "slack-ops"
type        = "slack"
webhook_url = "${SLACK_WEBHOOK_URL}"

[tasks.report-export]
cron              = "15 2 * * *"
retry_attempts    = 2
retry_delay       = "10s"
retry_backoff     = "exponential"
notify_on_failure = ["slack-ops"]
run               = "/usr/local/bin/export-report.sh"

Slack, Discord, Telegram, SMTP, and a generic webhook all ship in the binary. There’s also an in-app bell that needs no configuration at all, which is the one you’ll actually use at first.

Six things that will bite you

Your container is in UTC. So cron = "0 3 * * *" means 3 AM UTC, not 3 AM where you live. Set [scheduler] timezone (or timezone on a single task) and check the chip in the dashboard header, which shows the resolved zone and where it came from.

Missed firings come back. catch_up defaults to "latest", so if you docker compose down to deploy at 02:55 and come back at 03:10, that backup runs when the daemon starts. Usually what you want for a backup. Definitely not what you want for a job that posts to a customer-facing channel. Set catch_up = "skip" on those. Host cron just loses the firing, so this one surprises people.

Editing the TOML does nothing on its own. There’s no file watcher, on purpose. Run docker compose exec runwisp runwisp reload -c /etc/runwisp/runwisp.toml --data /var/lib/runwisp and it prints a diff of what changed. Pass both flags or it goes looking for a daemon in the wrong place and tells you nothing is running. It validates first, so a typo gets rejected and the running jobs carry on untouched rather than half-applying.

The base image has almost nothing in it. No bash, no curl, no pg_dump, no python. That’s the point (it keeps the image small), but it means apk add whatever your tasks need, or exec into a container that already has it.

timeout can’t reach inside a docker exec. Docker has no way to cancel an exec, so a timeout kills RunWisp’s local client while the process inside the container keeps going. If the job could hang, bound it on the inside too: docker exec acme-db timeout 300 pg_dump ….

Long jobs overlap. A */5 job that sometimes takes six minutes will queue up behind itself, and on_overlap defaults to queue for tasks. Use skip for healthchecks and backups, where a late run is worthless.

When you shouldn’t do this

More than one host, or Swarm, or Kubernetes: this is a single-host tool by design, no clustering and no leader election. Use a CronJob and keep your life simple.

Jobs that depend on other jobs: RunWisp tasks are independent units and it is not a DAG engine. Reach for Dagu or Airflow.

Already running Ofelia and happy with it: honestly, three labels is hard to beat for pure scheduling. The trade you’re making is history, retries, captured output, and alerting. If you don’t need those, you don’t need this. We wrote the whole comparison out in RunWisp vs Ofelia.

The short version

services:
    runwisp:
        image: runwisp/runwisp:latest
        ports: ["9477:9477"]
        environment:
            - RUNWISP_PASSWORD=change-me
            - RUNWISP_SOCKET=/var/lib/runwisp/runwisp.sock
        command: runwisp daemon -c /etc/runwisp/runwisp.toml --data /var/lib/runwisp --host 0.0.0.0
        volumes:
            - ./runwisp.toml:/etc/runwisp/runwisp.toml:ro
            - /var/run/docker.sock:/var/run/docker.sock
            - runwisp-data:/var/lib/runwisp

volumes:
    runwisp-data:

Add apk add docker-cli on top if your jobs need to reach sibling containers, write your schedules in runwisp.toml, open :9477.

The quick start has the non-Docker install, the configuration reference has every key, and the code is on GitHub. It’s pre-1.0 and it’s GPL 3.0, so read the changelog before you upgrade.

Common questions

How do I run a cron job inside a docker-compose service?
Run a scheduler as its own compose service, give it the Docker CLI and the Docker socket, and have each job exec into the target container (docker exec acme-db pg_dump ...). The alternative is running the job as a one-shot container from a compose service definition, which gives you a fresh container per firing. Installing crond inside your app image works too, but then the container runs two processes and the job output has nowhere useful to go.
Should cron run on the host or inside a container?
Either works, and the choice matters less than where the output ends up. A host crontab keeps the schedule outside your repo and mails failures to a spool nobody reads. A scheduler in the compose file keeps the schedule versioned next to the services it operates on, and can persist per-run exit codes and output to a volume.
Why does my containerized scheduler's web UI refuse connections?
Almost always because the daemon is bound to 127.0.0.1 inside the container, which means nothing outside it can connect no matter how you map the port. RunWisp defaults to loopback, so pass --host 0.0.0.0 explicitly. Keep the mapped port on a private network or behind a reverse proxy once you do.
What timezone do cron jobs use in Docker?
UTC, unless you change it. Container images almost never carry a local timezone, so 0 3 * * * fires at 3 AM UTC. In RunWisp set [scheduler] timezone to an IANA zone like Europe/Bratislava, or set timezone on an individual task.
What happens to a scheduled job if the container is down when it should fire?
In plain cron the firing is simply lost. RunWisp catches up instead: catch_up defaults to latest, so one missed run fires when the daemon comes back. That is usually right for a backup and usually wrong for anything that notifies people, so set catch_up = "skip" on those.
Do I need to restart the container after editing the config?
No. RunWisp reloads on demand with runwisp reload (there is no file watcher, by design). It validates the whole file before touching anything, so a typo is rejected and the running jobs keep going. A few settings, like the bind host and port, do need a restart.