Run cron jobs for Dockerized apps
Follow this on your own server. Start free, no card needed
Your app runs fine in Docker, but the one part that doesn’t survive the move is cron. There’s no cron daemon inside a typical application image, and even if you installed one, a container restart wipes whatever state it tracked. You need a nightly cleanup job, a report that goes out every Monday, or a cache warm every 15 minutes, and you need it to survive deploys, restarts, and the fact that your app runs as a single-purpose container, not a full Linux box with cron already running.
This walkthrough sets up a dedicated scheduler container, ofelia, that reads Docker labels off your existing containers and runs commands against them on a schedule. By the end you’ll have a working docker-compose.yml that runs a command inside your app container on a cron schedule, with logs you can check and a safe way to handle a job that fails.
What you’ll need
- A server with Docker and the Docker Compose plugin installed. A Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) is enough to run a small app plus the scheduler.
- Your app already running as a container, ideally via
docker-compose.yml. - No domain is required for the scheduler itself. If your app serves HTTP, whatever reverse proxy and HTTPS setup you already have for it is unaffected by anything in this guide.
- SSH access to the server.
Step 1: Understand why cron doesn’t just work here
A Docker image normally runs one process, your app, as PID 1. There’s nothing watching a crontab, because there’s no crontab. You have three honest ways to fix this:
- Bake
cron(or Alpine’scrond) into your app’s own image and run it alongside your app process with something likesupervisord. This works, but now your application image owns scheduling, and every unrelated job needs a code change and a rebuild to adjust. - Run a separate container, on the same schedule, that execs into your running app container to run a command. No changes to your app image at all.
- Run a separate container, on the same schedule, that starts a brand new short-lived container from an image, runs a one-off command, then throws the container away.
Option 2 and 3 are both handled by the same tool: ofelia, a small Go scheduler built specifically for Docker. It watches the Docker socket, reads job definitions from labels on your containers (or from a config file), and runs them on a cron schedule. This guide uses option 2, job-exec, since it’s the common case: you already have a running app container and you want a management command to run inside it periodically.
Step 2: Label the container that should run the job
Ofelia’s job-exec jobs are declared as labels on the container you want to run commands against. Here’s an app service with a nightly cleanup job attached:
services:
web:
image: ghcr.io/youraccount/yourapp:latest
restart: unless-stopped
ports:
- "3000:3000"
labels:
ofelia.enabled: "true"
ofelia.job-exec.nightly-cleanup.schedule: "30 3 * * *"
ofelia.job-exec.nightly-cleanup.command: "bin/cleanup"
ofelia.enabled: "true" is what tells the scheduler this container is allowed to have jobs run against it at all, so a container you forgot to label is never silently picked up. The label pattern is ofelia.<job-type>.<job-name>.<parameter>, so nightly-cleanup here is just a name you chose, it has no special meaning to ofelia. The schedule 30 3 * * * is a standard 5-field cron expression (minute, hour, day of month, month, day of week), read every night at 03:30. command is whatever you’d normally type inside the container, here a management script your image already ships.
Step 3: Add the scheduler service
Add ofelia itself as a second service in the same docker-compose.yml:
services:
web:
image: ghcr.io/youraccount/yourapp:latest
restart: unless-stopped
ports:
- "3000:3000"
labels:
ofelia.enabled: "true"
ofelia.job-exec.nightly-cleanup.schedule: "30 3 * * *"
ofelia.job-exec.nightly-cleanup.command: "bin/cleanup"
scheduler:
image: mcuadros/ofelia:a573727
restart: unless-stopped
depends_on:
- web
command: daemon --docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
mcuadros/ofelia doesn’t currently publish semantic-version tags on Docker Hub, only latest, development, and tags named after short git commit hashes. a573727 is one of those commit-hash tags and is confirmed present on Docker Hub today; pin whichever current hash tag you find there rather than floating on latest, so a scheduler upgrade is something you choose, not something that happens on a routine restart. The daemon --docker command tells ofelia to watch the Docker API for labeled containers instead of reading a static INI file.
Step 4: Mount the Docker socket, and know what that grants
The /var/run/docker.sock:/var/run/docker.sock:ro line is what lets ofelia see your containers and exec into them. It’s also, functionally, root on the host: a process with access to the Docker socket can start a new container with the host filesystem bind-mounted in, which is root access by another name. The :ro flag stops ofelia from writing to the socket file itself, but it does not restrict which Docker API calls it can make over that socket, so this is not a sandbox.
Keep that container off any image you don’t control, don’t expose the ofelia container to anything else on the network, and treat the server it runs on as a single trust boundary, not a multi-tenant box. If that tradeoff doesn’t sit well for your setup, job-run jobs (which launch a disposable container from a specified image instead of exec’ing into a long-running one) narrow the blast radius somewhat, since ofelia only needs permission to run that one image, not reach into arbitrary running containers.
Step 5: Deploy and verify the first run
Bring the stack up:
docker compose up -d
Ofelia logs every job it runs, including ones that fail, to its own container’s stdout:
docker compose logs -f scheduler
You should see a line like job "nightly-cleanup" started the first time the schedule fires, followed by job "nightly-cleanup" finished with the exit code. If you don’t want to wait until 03:30, temporarily add a second label with a schedule a few minutes out, confirm it runs, then remove the test label and redeploy.
Step 6: Handle failures without losing the output
A failed job doesn’t restart your container or alert anyone on its own. The exit code and command output live in docker compose logs scheduler, and that’s it unless you wire up something more. Ofelia supports a [global] config section for mail, Slack, or saved-log notifications, but those only apply when ofelia is run against a config file rather than pure Docker labels, since labels have no place to put SMTP credentials. If you need alerting, the practical option is to have your job’s own command report failure (a Slack webhook call, an error to your existing monitoring) rather than relying on the scheduler to notice.
For jobs where a missed run matters, keep the command itself idempotent, so that if a run fails partway through, the next scheduled run can simply retry rather than needing a human to intervene.
Step 7: Upgrading the scheduler and backing up its config
Ofelia itself holds no state of its own beyond what’s in your labels or config file, so there’s nothing to back up on the scheduler side beyond whatever you already keep in version control for docker-compose.yml. Upgrading is pulling a newer hash tag and recreating just that one service:
docker compose pull scheduler
docker compose up -d scheduler
Your app container keeps running unaffected, since docker compose up -d only touches services whose image or config actually changed.
The shortcut: do it with Railyard
If you deploy the app itself on Railyard rather than hand-rolling Compose, the scheduling problem goes away as a separate piece of infrastructure. Railyard’s Cron feature runs a command inside your app’s own image on a schedule you set, with run history kept per job, so 30 3 * * * becomes a line in the dashboard instead of a label and a second container. There’s no Docker socket to mount and no second image to keep patched, because the command runs using the same image your app already deploys.
The same Workers tab also picks up any background process your repository already declares, a worker or clock line in a Procfile, or a runner like Sidekiq, Solid Queue, GoodJob, or a Laravel queue worker found in your dependencies, and lets you add it as its own independently scaled process in one click. See Workers and scheduled jobs for the full picture of both, and Deploy from a repository for how the initial deploy itself works.
Troubleshooting
ofelia.enabled is set but the job never runs. Check that the label is on the target container (the one you want the command to run inside), not just on the scheduler. For job-exec, the label pair lives on the app container; only job-local and global config entries live on the scheduler itself.
The scheduler container can’t reach the Docker socket. Confirm the volume mount path matches your host’s actual socket location, /var/run/docker.sock is standard on most Linux distributions, but a Docker Desktop or rootless Docker setup sometimes places it elsewhere. docker compose logs scheduler will show a permission or connection error on startup if this is wrong.
Cron syntax looks right but the job fires at the wrong time. Ofelia runs on the container’s own clock in UTC by default unless you’ve set TZ on the scheduler container; 30 3 * * * means 03:30 UTC, not 03:30 in your local timezone, unless you’ve explicitly configured otherwise.
A job that touches files fails with a permissions error. This usually means the command is trying to write somewhere the app’s own container user can’t, independent of ofelia. Test the same command with docker compose exec web <command> directly to isolate scheduler issues from permission issues.
The server runs low on memory after adding the scheduler. Ofelia itself is small (around 12 MB compressed), so this is almost always the job’s own command, not the scheduler, consuming memory. Check the job’s resource use the same way you’d check any other process on the box.
FAQ
Do I need cron installed anywhere on the host? No. Ofelia replaces the host crontab entirely; nothing in this setup touches /etc/crontab or systemd timers.
Can I schedule a job that doesn’t need an existing running container? Yes, that’s job-run: it starts a new container from an image you specify, runs the command, and removes the container afterward, which is closer to a traditional “spin up, do the work, shut down” batch job.
What happens if two scheduled runs overlap, because one is still finishing when the next one starts? Ofelia doesn’t serialize job-exec runs against the same container by default, so a long-running job and a short interval can overlap. Keep the command either fast relative to its interval or safe to run concurrently with itself.
Does this work the same way on Docker Swarm or Kubernetes? Ofelia also supports job-service-run for Swarm services. On Kubernetes, a CronJob resource is the native equivalent and doesn’t need ofelia at all.
Is there a web UI to see job history? Not in the open-source image by itself; job history lives in whatever log output and retention you configure for the scheduler container.
A scheduler container and a couple of labels is a small amount of infrastructure for what a cron job needs, and it’s infrastructure you now have to patch, monitor, and explain to the next person who opens this repo. That tradeoff is worth it when you’re already running your own Compose stack by hand. When it isn’t, the job itself is still exactly as portable, since it’s just a command your image can already run.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.