← Blog

Rodando cron jobs em aplicações Dockerizadas

Railyard

9 de julho de 2026 · 10 min de leitura

Siga isso no seu próprio servidor. Comece grátis, sem cartão

Sua aplicação roda bem no Docker, mas a única parte que não sobrevive à mudança é o cron. Não existe daemon cron dentro de uma imagem de aplicação típica, e mesmo que você instalasse um, um restart do container apaga qualquer estado que ele estivesse rastreando. Você precisa de uma limpeza noturna, de um relatório que sai toda segunda-feira, ou de um aquecimento de cache a cada 15 minutos, e precisa que isso sobreviva a deploys, restarts e ao fato de que sua aplicação roda como um container de propósito único, não uma máquina Linux completa com cron já rodando.

Este passo a passo configura um container scheduler dedicado, o ofelia, que lê labels do Docker nos seus containers existentes e roda comandos contra eles numa agenda. Ao final você vai ter um docker-compose.yml funcionando que roda um comando dentro do seu container de aplicação numa agenda de cron, com logs que você pode checar e uma forma segura de lidar com um job que falha.

O que você vai precisar

  • Um servidor com Docker e o plugin do Docker Compose instalados. Um Hetzner CX23 (2 vCPU, 4 GB de RAM, 40 GB NVMe, €5,99/mês sem IVA) é suficiente pra rodar uma aplicação pequena mais o scheduler.
  • Sua aplicação já rodando como container, idealmente via docker-compose.yml.
  • Nenhum domínio é necessário pro scheduler em si. Se sua aplicação serve HTTP, o reverse proxy e a configuração de HTTPS que você já tem pra ela não são afetados por nada neste guia.
  • Acesso SSH ao servidor.

Passo 1: entender por que o cron simplesmente não funciona aqui

Uma imagem Docker normalmente roda um processo, sua aplicação, como PID 1. Não há nada observando um crontab, porque não existe crontab. Você tem três formas honestas de resolver isso:

  1. Embutir o cron (ou o crond do Alpine) na própria imagem da sua aplicação e rodá-lo junto com o processo da aplicação usando algo como supervisord. Isso funciona, mas agora sua imagem de aplicação é responsável pelo agendamento, e qualquer job sem relação precisa de uma mudança de código e um rebuild pra ser ajustado.
  2. Rodar um container separado, na mesma agenda, que entra no seu container de aplicação já em execução via exec pra rodar um comando. Nenhuma mudança na imagem da sua aplicação.
  3. Rodar um container separado, na mesma agenda, que inicia um container novo e efêmero a partir de uma imagem, roda um comando pontual, e descarta o container depois.

As opções 2 e 3 são cobertas pela mesma ferramenta: o ofelia, um scheduler pequeno em Go construído especificamente pro Docker. Ele observa o socket do Docker, lê definições de job a partir de labels nos seus containers (ou de um arquivo de configuração), e os roda numa agenda de cron. Este guia usa a opção 2, job-exec, já que é o caso comum: você já tem um container de aplicação rodando e quer que um comando de gerenciamento rode dentro dele periodicamente.

Passo 2: colocar labels no container que deve rodar o job

Os jobs job-exec do ofelia são declarados como labels no container contra o qual você quer rodar comandos. Aqui está um serviço de aplicação com uma limpeza noturna anexada:

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" é o que diz ao scheduler que esse container tem permissão pra ter jobs rodados contra ele, então um container que você esqueceu de marcar nunca é pego silenciosamente. O padrão da label é ofelia.<job-type>.<job-name>.<parameter>, então nightly-cleanup aqui é só um nome que você escolheu, sem nenhum significado especial pro ofelia. A agenda 30 3 * * * é uma expressão cron padrão de 5 campos (minuto, hora, dia do mês, mês, dia da semana), lida toda noite às 03:30. command é o que você normalmente digitaria dentro do container, aqui um script de gerenciamento que sua imagem já traz.

Passo 3: adicionar o serviço do scheduler

Adicione o próprio ofelia como um segundo serviço no mesmo 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

O mcuadros/ofelia não publica atualmente tags de versão semântica no Docker Hub, só latest, development, e tags nomeadas com hashes curtos de commit do git. a573727 é uma dessas tags de hash e está confirmada presente no Docker Hub hoje; fixe o hash atual que você encontrar lá em vez de flutuar no latest, pra que uma atualização do scheduler seja algo que você escolhe fazer, não algo que acontece num restart de rotina. O comando daemon --docker diz ao ofelia pra observar a API do Docker procurando containers com labels em vez de ler um arquivo INI estático.

Passo 4: montar o socket do Docker, e saber o que isso concede

A linha /var/run/docker.sock:/var/run/docker.sock:ro é o que permite ao ofelia ver seus containers e entrar neles via exec. É também, na prática, root no host: um processo com acesso ao socket do Docker pode iniciar um container novo com o filesystem do host montado dentro, o que é acesso root com outro nome. A flag :ro impede o ofelia de escrever no próprio arquivo do socket, mas não restringe quais chamadas da API do Docker ele pode fazer por esse socket, então isso não é um sandbox.

Mantenha esse container fora de qualquer imagem que você não controla, não exponha o container do ofelia pra mais nada na rede, e trate o servidor onde ele roda como uma única fronteira de confiança, não uma máquina multi-tenant. Se esse tradeoff não combinar bem com a sua configuração, jobs job-run (que lançam um container descartável a partir de uma imagem especificada em vez de entrar via exec num que já está rodando) reduzem um pouco o raio de impacto, já que o ofelia só precisa de permissão pra rodar aquela única imagem, não pra acessar containers arbitrários em execução.

Passo 5: fazer deploy e verificar a primeira execução

Suba o stack:

docker compose up -d

O ofelia registra todo job que roda, incluindo os que falham, no stdout do próprio container:

docker compose logs -f scheduler

Você deve ver uma linha como job "nightly-cleanup" started na primeira vez que a agenda disparar, seguida de job "nightly-cleanup" finished com o código de saída. Se você não quiser esperar até 03:30, adicione temporariamente uma segunda label com uma agenda a poucos minutos de distância, confirme que roda, depois remova a label de teste e faça o deploy de novo.

Passo 6: lidar com falhas sem perder a saída

Um job que falha não reinicia seu container nem alerta ninguém por conta própria. O código de saída e a saída do comando vivem em docker compose logs scheduler, e é só isso, a menos que você monte algo mais. O ofelia suporta uma seção de configuração [global] pra notificações por e-mail, Slack, ou log salvo, mas essas só se aplicam quando o ofelia é executado contra um arquivo de configuração em vez de labels puras do Docker, já que labels não têm onde colocar credenciais SMTP. Se você precisa de alertas, a opção prática é fazer o próprio comando do seu job reportar a falha (uma chamada de webhook do Slack, um erro pro seu monitoramento já existente) em vez de depender do scheduler pra notar.

Pra jobs em que uma execução perdida importa, mantenha o próprio comando idempotente, pra que, se uma execução falhar no meio do caminho, a próxima execução agendada simplesmente tente de novo em vez de precisar de um humano pra intervir.

Passo 7: atualizando o scheduler e fazendo backup da configuração dele

O próprio ofelia não guarda nenhum estado além do que está nas suas labels ou no arquivo de configuração, então não há nada pra fazer backup do lado do scheduler além do que você já mantém no controle de versão pro docker-compose.yml. Atualizar é puxar uma tag de hash mais nova e recriar só aquele serviço:

docker compose pull scheduler
docker compose up -d scheduler

Seu container de aplicação continua rodando sem ser afetado, já que docker compose up -d só toca nos serviços cuja imagem ou configuração de fato mudou.

O caminho mais curto: fazer isso com o Railyard

Se você fizer deploy da própria aplicação no Railyard em vez de montar o Compose à mão, o problema do agendamento desaparece como uma peça separada de infraestrutura. A funcionalidade Cron do Railyard roda um comando dentro da própria imagem da sua aplicação numa agenda que você define, com histórico de execução mantido por job, então 30 3 * * * se torna uma linha no dashboard em vez de uma label e um segundo container. Não há socket do Docker pra montar e nenhuma segunda imagem pra manter corrigida, porque o comando roda usando a mesma imagem que sua aplicação já faz deploy.

A mesma aba Workers também detecta qualquer processo em segundo plano que seu repositório já declara, uma linha worker ou clock num Procfile, ou um runner como Sidekiq, Solid Queue, GoodJob, ou um queue worker do Laravel encontrado nas suas dependências, e deixa você adicioná-lo como um processo escalado independentemente em um clique. Veja Workers and scheduled jobs pro quadro completo dos dois, e Deploy from a repository pra como o deploy inicial em si funciona.

Resolução de problemas

ofelia.enabled está definido mas o job nunca roda. Confira se a label está no container alvo (aquele dentro do qual você quer que o comando rode), não só no scheduler. Pra job-exec, o par de label vive no container da aplicação; só entradas job-local e de configuração global vivem no próprio scheduler.

O container do scheduler não consegue acessar o socket do Docker. Confirme que o caminho do volume montado corresponde à localização real do socket no seu host, /var/run/docker.sock é padrão na maioria das distribuições Linux, mas uma configuração de Docker Desktop ou Docker rootless às vezes coloca em outro lugar. docker compose logs scheduler vai mostrar um erro de permissão ou conexão na inicialização se isso estiver errado.

A sintaxe do cron parece certa, mas o job dispara na hora errada. O ofelia roda no relógio do próprio container em UTC por padrão, a menos que você tenha definido TZ no container do scheduler; 30 3 * * * significa 03:30 UTC, não 03:30 no seu fuso horário local, a menos que você tenha configurado explicitamente o contrário.

Um job que toca em arquivos falha com um erro de permissão. Isso geralmente significa que o comando está tentando escrever em algum lugar onde o próprio usuário do container da aplicação não pode, independente do ofelia. Teste o mesmo comando com docker compose exec web <command> diretamente pra isolar problemas do scheduler de problemas de permissão.

O servidor fica com pouca memória depois de adicionar o scheduler. O próprio ofelia é pequeno (cerca de 12 MB comprimido), então isso é quase sempre o comando do próprio job, não o scheduler, consumindo memória. Cheque o uso de recursos do job do mesmo jeito que você checaria qualquer outro processo na máquina.

Perguntas frequentes

Eu preciso ter cron instalado em algum lugar do host? Não. O ofelia substitui o crontab do host por completo; nada nessa configuração toca em /etc/crontab ou nos timers do systemd.

Eu posso agendar um job que não precisa de um container já em execução? Sim, é o job-run: ele inicia um container novo a partir de uma imagem que você especifica, roda o comando, e remove o container depois, o que é mais parecido com um job em lote tradicional de “ligar, fazer o trabalho, desligar”.

O que acontece se duas execuções agendadas se sobrepõem, porque uma ainda está terminando quando a próxima começa? O ofelia não serializa execuções de job-exec contra o mesmo container por padrão, então um job de longa duração e um intervalo curto podem se sobrepor. Mantenha o comando rápido em relação ao seu intervalo, ou seguro pra rodar concorrentemente com ele mesmo.

Isso funciona do mesmo jeito no Docker Swarm ou no Kubernetes? O ofelia também suporta job-service-run pra serviços do Swarm. No Kubernetes, um recurso CronJob é o equivalente nativo e não precisa do ofelia de forma alguma.

Existe uma interface web pra ver o histórico de jobs? Não na imagem open-source por si só; o histórico de jobs vive em qualquer saída de log e retenção que você configurar pro container scheduler.

Um container scheduler e algumas labels são uma quantidade pequena de infraestrutura pelo que um cron job precisa, e é infraestrutura que agora você tem que corrigir, monitorar e explicar pra próxima pessoa que abrir esse repositório. Esse tradeoff vale a pena quando você já está rodando seu próprio stack do Compose à mão. Quando não vale, o job em si continua exatamente tão portável, já que é só um comando que sua imagem já consegue rodar.

Por que escolhem o Railyard para isso

  • Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
  • Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
  • Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
  • Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
  • Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.

Pule o container scheduler

Faça deploy do repositório no Railyard e adicione o mesmo agendamento pela aba Cron, sem sidecar pra manter.

Plano grátis, sem cartão, e 14 dias de Pro para cada equipe nova. Um preço fixo por equipe.