← Blog

Cron-Jobs für dockerisierte Apps ausführen

Railyard

9. Juli 2026 · 8 Min. Lesezeit

Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig

Deine App läuft problemlos in Docker, aber der eine Teil, der den Umzug nicht überlebt, ist cron. In einem typischen Anwendungs-Image gibt es keinen cron-Daemon, und selbst wenn du einen installiert hättest, löscht ein Container-Restart jeden Zustand, den er verfolgt hat. Du brauchst einen nächtlichen Aufräum-Job, einen Report, der jeden Montag rausgeht, oder ein Cache-Warmup alle 15 Minuten – und das muss Deploys, Restarts und die Tatsache überstehen, dass deine App als Single-Purpose-Container läuft, nicht als vollständige Linux-Box mit bereits laufendem cron.

Diese Anleitung richtet einen dedizierten Scheduler-Container ein, ofelia, der Docker-Labels von deinen bestehenden Containern liest und Befehle nach einem Zeitplan gegen sie ausführt. Am Ende hast du eine funktionierende docker-compose.yml, die einen Befehl in deinem App-Container nach einem Cron-Zeitplan ausführt, mit Logs, die du prüfen kannst, und einer sicheren Methode, einen fehlgeschlagenen Job zu behandeln.

Was du brauchst

  • Einen Server mit Docker und installiertem Docker-Compose-Plugin. Ein Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, 5,99 €/Monat exkl. MwSt.) reicht, um eine kleine App plus den Scheduler laufen zu lassen.
  • Deine App, die bereits als Container läuft, idealerweise über docker-compose.yml.
  • Für den Scheduler selbst ist keine Domain nötig. Falls deine App HTTP bedient, bleibt dein bestehender Reverse-Proxy und HTTPS-Setup von allem in diesem Guide unberührt.
  • SSH-Zugriff auf den Server.

Schritt 1: Verstehen, warum cron hier nicht einfach funktioniert

Ein Docker-Image führt normalerweise einen Prozess aus, deine App, als PID 1. Da ist nichts, das eine Crontab überwacht, weil es keine Crontab gibt. Du hast drei ehrliche Wege, das zu lösen:

  1. cron (oder Alpines crond) in das Image deiner eigenen App einbacken und neben dem App-Prozess mit etwas wie supervisord laufen lassen. Das funktioniert, aber jetzt besitzt dein Anwendungs-Image die Zeitplanung, und jeder unabhängige Job braucht eine Codeänderung und einen Rebuild zur Anpassung.
  2. Einen separaten Container mit demselben Zeitplan laufen lassen, der sich in deinen laufenden App-Container einklinkt (exec), um einen Befehl auszuführen. Keine Änderungen an deinem App-Image nötig.
  3. Einen separaten Container mit demselben Zeitplan laufen lassen, der einen brandneuen kurzlebigen Container aus einem Image startet, einen einmaligen Befehl ausführt und den Container danach wegwirft.

Optionen 2 und 3 werden beide vom selben Tool übernommen: ofelia, ein kleiner Go-Scheduler, speziell für Docker gebaut. Er beobachtet den Docker-Socket, liest Job-Definitionen aus Labels auf deinen Containern (oder aus einer Config-Datei) und führt sie nach einem Cron-Zeitplan aus. Dieser Guide nutzt Option 2, job-exec, weil das der übliche Fall ist: Du hast bereits einen laufenden App-Container und willst periodisch einen Management-Befehl darin ausführen.

Schritt 2: Den Container labeln, der den Job ausführen soll

Ofelias job-exec-Jobs werden als Labels auf dem Container deklariert, gegen den du Befehle ausführen willst. Hier ein App-Service mit einem angehängten nächtlichen Cleanup-Job:

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" sagt dem Scheduler überhaupt erst, dass gegen diesen Container Jobs ausgeführt werden dürfen, sodass ein Container, den du zu labeln vergessen hast, niemals still mit aufgenommen wird. Das Label-Muster ist ofelia.<job-type>.<job-name>.<parameter>, nightly-cleanup hier ist also nur ein Name, den du gewählt hast, er hat für ofelia keine besondere Bedeutung. Der Zeitplan 30 3 * * * ist ein standardmäßiger 5-Felder-Cron-Ausdruck (Minute, Stunde, Tag des Monats, Monat, Wochentag), gelesen jede Nacht um 03:30. command ist, was du normalerweise innerhalb des Containers eintippen würdest, hier ein Management-Skript, das dein Image bereits mitbringt.

Schritt 3: Den Scheduler-Service hinzufügen

Füge ofelia selbst als zweiten Service in derselben docker-compose.yml hinzu:

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 veröffentlicht derzeit keine semantischen Versions-Tags auf Docker Hub, nur latest, development und Tags, die nach kurzen Git-Commit-Hashes benannt sind. a573727 ist einer dieser Commit-Hash-Tags und heute bestätigt auf Docker Hub vorhanden; pinne, welchen aktuellen Hash-Tag du dort findest, statt auf latest zu floaten, damit ein Scheduler-Upgrade etwas ist, das du wählst, und nicht etwas, das bei einem routinemäßigen Restart passiert. Der Befehl daemon --docker sagt ofelia, über die Docker-API nach gelabelten Containern zu suchen, statt eine statische INI-Datei zu lesen.

Schritt 4: Den Docker-Socket mounten, und wissen, was das gewährt

Die Zeile /var/run/docker.sock:/var/run/docker.sock:ro ist, was ofelia erlaubt, deine Container zu sehen und in sie hineinzuexec’en. Funktional ist das auch Root auf dem Host: Ein Prozess mit Zugriff auf den Docker-Socket kann einen neuen Container mit eingehängtem Host-Dateisystem starten, was Root-Zugriff unter anderem Namen ist. Das :ro-Flag verhindert, dass ofelia in die Socket-Datei selbst schreibt, schränkt aber nicht ein, welche Docker-API-Aufrufe es über diesen Socket machen kann – das ist also keine Sandbox.

Halte diesen Container von jedem Image fern, das du nicht kontrollierst, exponiere den ofelia-Container gegenüber nichts anderem im Netzwerk, und behandle den Server, auf dem er läuft, als einzelne Vertrauensgrenze, nicht als Multi-Tenant-Box. Wenn dir dieser Trade-off für dein Setup nicht passt, grenzen job-run-Jobs (die einen Wegwerf-Container aus einem angegebenen Image starten, statt in einen langlaufenden zu exec’en) den Blast-Radius etwas ein, da ofelia dann nur die Berechtigung braucht, dieses eine Image auszuführen, nicht in beliebige laufende Container hineinzureichen.

Schritt 5: Deployen und den ersten Lauf verifizieren

Den Stack hochfahren:

docker compose up -d

Ofelia loggt jeden Job, den es ausführt, inklusive fehlgeschlagener, auf das Stdout des eigenen Containers:

docker compose logs -f scheduler

Du solltest eine Zeile wie job "nightly-cleanup" started sehen, wenn der Zeitplan zum ersten Mal auslöst, gefolgt von job "nightly-cleanup" finished mit dem Exit-Code. Wenn du nicht bis 03:30 warten willst, füge vorübergehend ein zweites Label mit einem Zeitplan ein paar Minuten in der Zukunft hinzu, bestätige, dass es läuft, entferne dann das Test-Label und deploye erneut.

Schritt 6: Fehler behandeln, ohne die Ausgabe zu verlieren

Ein fehlgeschlagener Job startet deinen Container nicht neu und alarmiert von sich aus niemanden. Der Exit-Code und die Befehlsausgabe leben in docker compose logs scheduler, und das war’s, solange du nicht etwas Weiteres verdrahtest. Ofelia unterstützt einen [global]-Config-Abschnitt für Mail-, Slack- oder Saved-Log-Benachrichtigungen, aber die greifen nur, wenn ofelia gegen eine Config-Datei läuft statt gegen reine Docker-Labels, da Labels keinen Platz für SMTP-Zugangsdaten haben. Wenn du Alarmierung brauchst, ist die praktische Option, dass der Befehl deines Jobs selbst Fehler meldet (ein Slack-Webhook-Aufruf, ein Fehler an dein bestehendes Monitoring), statt sich darauf zu verlassen, dass der Scheduler es merkt.

Bei Jobs, bei denen ein verpasster Lauf wichtig ist, halte den Befehl selbst idempotent, sodass ein nächster geplanter Lauf bei einem teilweisen Fehlschlag einfach erneut versuchen kann, statt dass ein Mensch eingreifen muss.

Schritt 7: Scheduler upgraden und seine Konfiguration sichern

Ofelia selbst hält keinen eigenen Zustand über das hinaus, was in deinen Labels oder der Config-Datei steht, also gibt es auf der Scheduler-Seite nichts zu sichern, abgesehen von dem, was du bereits in der Versionskontrolle für docker-compose.yml behältst. Upgraden heißt, einen neueren Hash-Tag zu pullen und genau diesen einen Service neu zu erstellen:

docker compose pull scheduler
docker compose up -d scheduler

Dein App-Container läuft unberührt weiter, da docker compose up -d nur Services berührt, deren Image oder Config sich tatsächlich geändert hat.

Die Abkürzung: mit Railyard machen

Deployst du die App selbst auf Railyard, statt Compose von Hand zu basteln, verschwindet das Scheduling-Problem als separates Infrastrukturstück. Railyards Cron-Feature führt einen Befehl im eigenen Image deiner App nach einem von dir gesetzten Zeitplan aus, mit Lauf-Historie pro Job – 30 3 * * * wird so zu einer Zeile im Dashboard statt zu einem Label und einem zweiten Container. Es gibt keinen Docker-Socket zu mounten und kein zweites Image zu patchen, da der Befehl mit demselben Image läuft, das deine App bereits deployt.

Derselbe Workers-Tab erkennt auch jeden Hintergrundprozess, den dein Repository bereits deklariert, eine worker- oder clock-Zeile in einem Procfile, oder einen Runner wie Sidekiq, Solid Queue, GoodJob oder einen Laravel-Queue-Worker, der in deinen Dependencies gefunden wird, und lässt dich ihn mit einem Klick als eigenständig skalierten Prozess hinzufügen. Siehe Workers und geplante Jobs für das vollständige Bild zu beiden, und Von einem Repository deployen dafür, wie der initiale Deploy selbst funktioniert.

Troubleshooting

ofelia.enabled ist gesetzt, aber der Job läuft nie. Prüfe, dass das Label auf dem Ziel-Container sitzt (dem, in dem der Befehl ausgeführt werden soll), nicht nur auf dem Scheduler. Bei job-exec liegt das Label-Paar auf dem App-Container; nur job-local und globale Config-Einträge liegen auf dem Scheduler selbst.

Der Scheduler-Container kann den Docker-Socket nicht erreichen. Bestätige, dass der Volume-Mount-Pfad dem tatsächlichen Socket-Pfad deines Hosts entspricht, /var/run/docker.sock ist auf den meisten Linux-Distributionen Standard, aber ein Docker-Desktop- oder rootless-Docker-Setup legt ihn manchmal anderswo an. docker compose logs scheduler zeigt beim Start einen Berechtigungs- oder Verbindungsfehler, wenn das falsch ist.

Cron-Syntax sieht richtig aus, aber der Job läuft zur falschen Zeit. Ofelia läuft standardmäßig nach der eigenen Uhr des Containers in UTC, sofern du nicht TZ auf dem Scheduler-Container gesetzt hast; 30 3 * * * bedeutet 03:30 UTC, nicht 03:30 in deiner lokalen Zeitzone, sofern du nichts anderes explizit konfiguriert hast.

Ein Job, der Dateien berührt, schlägt mit einem Berechtigungsfehler fehl. Das bedeutet meist, dass der Befehl versucht, irgendwohin zu schreiben, wo der eigene Container-User der App nicht darf, unabhängig von ofelia. Teste denselben Befehl direkt mit docker compose exec web <command>, um Scheduler-Probleme von Berechtigungsproblemen zu isolieren.

Dem Server geht nach Hinzufügen des Schedulers der Speicher aus. Ofelia selbst ist klein (etwa 12 MB komprimiert), das ist also fast immer der Befehl des Jobs selbst, nicht der Scheduler, der Speicher verbraucht. Prüfe den Ressourcenverbrauch des Jobs genauso, wie du jeden anderen Prozess auf der Box prüfen würdest.

FAQ

Muss ich cron irgendwo auf dem Host installieren? Nein. Ofelia ersetzt die Host-Crontab vollständig; nichts in diesem Setup berührt /etc/crontab oder systemd-Timer.

Kann ich einen Job planen, der keinen bestehenden laufenden Container braucht? Ja, das ist job-run: Er startet einen neuen Container aus einem von dir angegebenen Image, führt den Befehl aus und entfernt den Container danach, was näher an einem traditionellen “hochfahren, die Arbeit tun, herunterfahren”-Batch-Job ist.

Was passiert, wenn sich zwei geplante Läufe überlappen, weil einer noch läuft, wenn der nächste startet? Ofelia serialisiert job-exec-Läufe gegen denselben Container standardmäßig nicht, sodass sich ein langlaufender Job und ein kurzes Intervall überlappen können. Halte den Befehl entweder schnell relativ zu seinem Intervall oder sicher für gleichzeitige Ausführung mit sich selbst.

Funktioniert das auf Docker Swarm oder Kubernetes genauso? Ofelia unterstützt auch job-service-run für Swarm-Services. Auf Kubernetes ist eine CronJob-Ressource das native Äquivalent und braucht ofelia überhaupt nicht.

Gibt es ein Web-UI, um die Job-Historie zu sehen? Nicht im Open-Source-Image allein; die Job-Historie lebt in der Log-Ausgabe und Aufbewahrung, die du für den scheduler-Container konfigurierst.

Ein Scheduler-Container und ein paar Labels sind ein kleines Stück Infrastruktur für das, was ein Cron-Job braucht, aber es ist Infrastruktur, die du jetzt patchen, überwachen und der nächsten Person erklären musst, die dieses Repo öffnet. Dieser Trade-off lohnt sich, wenn du deinen eigenen Compose-Stack ohnehin von Hand betreibst. Wenn nicht, ist der Job selbst immer noch genauso portabel, da er einfach ein Befehl ist, den dein Image bereits ausführen kann.

Warum Leute sich dafür Railyard entscheiden

  • Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
  • Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
  • Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
  • Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
  • Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.

Spar dir den Scheduler-Container

Deploye das Repo auf Railyard und füge denselben Zeitplan über den Cron-Tab hinzu, kein Sidecar zu pflegen.

Kostenloser Plan, keine Kreditkarte, und 14 Tage Pro für jedes neue Team. Ein fester Preis pro Team.