Ofelia Configuration Guide
June 25, 2026 · View on GitHub
Configuration Methods
Ofelia supports multiple configuration methods that can be used independently or combined:
- INI Configuration File (Traditional, static configuration)
- Docker Labels (Dynamic, container-specific configuration)
- Environment Variables (Override specific settings)
- Command-line Flags (Runtime overrides)
Configuration Precedence
Daemon/global settings are evaluated in the following order (highest to lowest priority):
- Command-line flags
- Environment variables
- INI configuration file
- Docker labels
Job state uses a separate precedence — for jobs with the same name across sources:
- API state file (
--state-file) — highest; persisted API/UI mutations are authoritative for their own job names - Docker labels
- INI configuration file
Disable flags from the state file apply on top regardless of where the underlying job came from, so an INI/label job paused via the UI stays paused after restart.
State File (--state-file)
When the daemon runs with --state-file=/path/to/state.json (or OFELIA_STATE_FILE=...), Ofelia persists API-mutated state to disk so changes survive restarts (#593):
- Jobs created/updated via
POST /api/jobs/createor/api/jobs/updateare recorded on each successful call and reapplied on the next daemon boot - Disable flags (
POST /api/jobs/disable//enable) are persisted regardless of the job's origin — operators can pause an INI-defined job from the UI and the pause survives restart
Lifecycle:
- Boot loads INI + scans Docker labels (unchanged)
- State file loads last; persisted API-origin jobs shadow same-named INI/label jobs
- Persisted disable flags are applied to whatever jobs ended up in the scheduler
Delete semantics:
POST /api/jobs/deleteis only allowed against API-origin jobs; INI/Docker-label jobs return 403 with a message naming the origin so operators know which source file to edit. (Pre-fix this handler silently removed any job from memory until the next reload — a sharp edge that #593 tightens.)- To suppress an INI/label job temporarily, use
POST /api/jobs/disableinstead — that does persist across restart.
On-disk format (JSON, atomic tmp+rename, schema-versioned):
{
"version": 1,
"jobs": {
"backup-db": {
"type": "run",
"schedule": "@daily",
"image": "postgres:15",
"command": "pg_dump mydb"
}
},
"disabled": ["paused-job"]
}
Operator workflow (typical):
services:
ofelia:
image: ghcr.io/netresearch/ofelia:latest
command: daemon --config=/etc/ofelia/config.ini --state-file=/var/lib/ofelia/state.json
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config.ini:/etc/ofelia/config.ini:ro
- ofelia-state:/var/lib/ofelia # mount-mapped so the file survives container recycle
volumes:
ofelia-state:
Empty path = disabled (the default) preserves the pre-#593 behavior.
Hybrid Configuration (INI + Docker Labels)
A common pattern is using INI configuration for global settings (like email credentials) while using Docker labels for job definitions. This keeps sensitive credentials out of container metadata and allows dynamic job discovery.
Example Setup:
- Create INI config with global settings only (
/etc/ofelia/config.ini):
[global]
# Email notification settings (credentials hidden from labels)
smtp-host = smtp.gmail.com
smtp-port = 587
smtp-user = notifications@example.com
smtp-password = ${SMTP_PASSWORD}
email-from = notifications@example.com
email-to = admin@example.com
# Slack notifications
slack-webhook = https://hooks.slack.com/services/XXX/YYY/ZZZ
slack-only-on-error = true
# Output settings
save-folder = /var/log/ofelia
save-only-on-error = true
# No job definitions in INI - jobs come from Docker labels
- Docker Compose with config volume and labels (
docker-compose.yml):
version: '3.8'
services:
ofelia:
image: ghcr.io/netresearch/ofelia:latest
command: daemon --config=/etc/ofelia/config.ini
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config.ini:/etc/ofelia/config.ini:ro
- ./logs:/var/log/ofelia
environment:
- SMTP_PASSWORD=${SMTP_PASSWORD} # Injected from .env file
database:
image: postgres:15
labels:
ofelia.enabled: "true"
# Jobs inherit email settings from INI [global] section
ofelia.job-exec.backup.schedule: "@daily"
ofelia.job-exec.backup.command: "pg_dump -U postgres mydb > /backup/db.sql"
ofelia.job-exec.backup.email-to: "dba@example.com" # Override recipient
How It Works:
- Ofelia always reads the config file first (defaults to
/etc/ofelia/config.ini) - Global settings (email, slack, save options) are loaded from INI
- Docker labels provide job definitions dynamically
- Jobs inherit global notification settings unless explicitly overridden
- If a job is defined in both INI and labels with the same name, the INI version takes precedence
Benefits:
- Credentials stay in config files, not exposed in
docker inspect - Jobs can be added/removed by updating container labels
- Global settings centralized in one place
- Environment variable substitution for secrets (
${SMTP_PASSWORD})
Note
Please check the Include stopped containers documentation below if you are using --docker-include-stopped flag.
Labels-Only Configuration (No INI File)
Ofelia can run entirely without an INI configuration file, using only Docker labels and environment variables. This is ideal for simple setups, Kubernetes environments, or when you want all configuration in one place.
When the INI file is missing or unreadable, Ofelia:
- Logs a warning but continues running
- Creates an empty internal configuration
- Relies entirely on Docker labels for job definitions
- Uses environment variables for daemon settings
Example: Pure Docker Labels Setup
version: '3.8'
services:
ofelia:
image: ghcr.io/netresearch/ofelia:latest
command: daemon --docker-events
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
# Daemon settings via environment variables
- OFELIA_LOG_LEVEL=info
- OFELIA_ENABLE_WEB=true
- OFELIA_WEB_ADDRESS=:8081
labels:
# Mark this as the Ofelia service container
ofelia.service: "true"
ofelia.enabled: "true"
# Global settings via labels (on Ofelia container with ofelia.service=true)
ofelia.slack-webhook: "https://hooks.slack.com/services/XXX/YYY/ZZZ"
ofelia.slack-only-on-error: "true"
# Webhook notifications (recommended over deprecated slack-webhook)
ofelia.webhook.slack.preset: "slack"
ofelia.webhook.slack.id: "T00000000/B00000000000"
ofelia.webhook.slack.secret: "XXXXXXXXXXXXXXXXXXXXXXXX"
ofelia.webhook.slack.trigger: "error"
ofelia.webhook-webhooks: "slack"
# job-run can be defined on Ofelia container
ofelia.job-run.cleanup.schedule: "@daily"
ofelia.job-run.cleanup.image: "alpine:latest"
ofelia.job-run.cleanup.command: "echo 'Daily cleanup'"
ofelia.job-run.cleanup.delete: "true"
ports:
- "8081:8081"
app:
image: myapp:latest
labels:
ofelia.enabled: "true"
# job-exec defined on target container
ofelia.job-exec.health.schedule: "*/5 * * * *"
ofelia.job-exec.health.command: "curl -f http://localhost:8080/health"
Key Points:
| Setting Type | Configuration Method | Notes |
|---|---|---|
| Daemon options | Environment variables | OFELIA_* prefix |
| Global notifications | Labels on Ofelia container | Requires ofelia.service=true |
| job-exec | Labels on target container | Container auto-detected |
| job-run, job-local, job-service-run | Labels on Ofelia container | Requires ofelia.service=true |
| Webhook definitions | Labels on Ofelia container | Requires ofelia.service=true |
Available Environment Variables:
| Variable | Description | Default |
|---|---|---|
OFELIA_CONFIG | Config file path | /etc/ofelia/config.ini |
OFELIA_LOG_LEVEL | Logging level (DEBUG, INFO, WARNING, ERROR) | INFO |
OFELIA_DOCKER_FILTER | Docker container filter | (none) |
OFELIA_POLL_INTERVAL | Deprecated legacy poll interval (affects config and container polling) | (unset) |
OFELIA_DOCKER_EVENTS | Use Docker events instead of polling | true |
OFELIA_DOCKER_NO_POLL | Disable Docker polling | false |
OFELIA_DOCKER_INCLUDE_STOPPED | Include stopped containers when reading Docker labels (only for job-run) | false |
OFELIA_ENABLE_WEB | Enable web UI | false |
OFELIA_WEB_ADDRESS | Web UI bind address | :8081 |
OFELIA_WEB_AUTH_ENABLED | Enable web UI authentication | false |
OFELIA_WEB_USERNAME | Web UI username | (none) |
OFELIA_WEB_PASSWORD_HASH | bcrypt hash of password | (none) |
OFELIA_WEB_SECRET_KEY | Secret for token signing | (auto-generated) |
OFELIA_WEB_TOKEN_EXPIRY | Token expiry in hours | 24 |
OFELIA_WEB_MAX_LOGIN_ATTEMPTS | Max login attempts per minute | 5 |
OFELIA_ENABLE_PPROF | Enable pprof profiling | false |
OFELIA_PPROF_ADDRESS | pprof bind address | 127.0.0.1:8080 |
OFELIA_STATE_FILE | JSON file persisting API-mutated jobs and disable flags across restarts (#593) | (none, disabled) |
Limitations of Labels-Only Configuration:
- No environment variable substitution in label values (
${VAR}won't expand) - Sensitive values (passwords, API keys) visible in
docker inspect - Global notification settings require
ofelia.service=trueon Ofelia container - Per-job SMTP credentials not recommended (use INI for credentials)
When to Use Labels-Only:
- Simple setups without email notifications
- Slack-only notifications (webhook URL is less sensitive)
- Development and testing environments
- When all configuration should be in docker-compose.yml
When to Use Hybrid (INI + Labels):
- Production environments with email notifications
- When credentials must be protected
- When you need environment variable substitution for secrets
Include stopped containers (OFELIA_DOCKER_INCLUDE_STOPPED, --docker-include-stopped)
You can enable include-stopped via the env var OFELIA_DOCKER_INCLUDE_STOPPED, the flag --docker-include-stopped, or in config under [docker] as include-stopped = true. Default is false.
Purpose
- Job-run on stopped containers: This option is for running job-run jobs whose Ofelia labels are defined on stopped containers (e.g. scheduled backup, configure or migrate tasks on containers that stops after executing a task). Other job types (job-exec, job-local, job-service-run, job-compose) from stopped containers are ignored; only job-run labels are parsed on stopped containers.
- Decentralization: Each service can own its job-run definitions via Docker labels on its own container instead of configuring them only on the Ofelia service container.
Behaviour
- When
include-stoppedis true, Ofelia searches for job-run labels across all matching containers (running and stopped). Stopped containers are included in the label scan. - Only one definition per job-run name is kept. Definitions from running containers take precedence over definitions from stopped containers.
Recommendations
- Set up container pruning (or a clear lifecycle for stopped containers) so that old stopped containers with Ofelia labels do not accumulate and are not used unintentionally.
- Avoid defining the same job-run name on multiple stopped containers if you need a predictable result.
- Prefer specifying a Docker filter (
--docker-filteror[docker]filters) to limit which containers Ofelia inspects; this reduces the set of running and stopped containers considered.
INI Configuration
Environment Variable Substitution
INI config files support ${VAR} syntax for environment variable substitution. Variables are resolved before the INI file is parsed.
| Syntax | Behavior |
|---|---|
${VAR} | Replaced with env value if defined and non-empty; kept as literal ${VAR} if undefined |
${VAR:-default} | Replaced with env value if defined and non-empty; uses default if undefined or empty |
[global]
smtp-host = ${SMTP_HOST:-mail.example.com}
smtp-password = ${SMTP_PASS}
[job-run "backup"]
schedule = @daily
image = ${BACKUP_IMAGE:-postgres:15}
command = pg_dump ${DB_NAME:-mydb}
Notes:
- Only
${VAR}syntax is supported —$VARwithout braces is not substituted, keeping cron expressions and shell commands safe. - Substitution happens in all config values, including
command. If your command uses${VAR}shell syntax, Ofelia will substitute it before the shell sees it. Use$VAR(without braces) in commands if you want shell expansion instead of Ofelia expansion. - Undefined variables without a default stay as the literal string
${VAR}, making typos visible in logs. - Defaults can contain special characters including colons (
${IMG:-nginx:1.25-alpine}) but not closing braces (}).
Tip: If you need advanced substitution features (error on undefined, conditional replacement), use Docker Compose's own variable substitution to set environment variables on the Ofelia container, then reference those variables in the INI config:
# compose.yml — Compose handles validation services: ofelia: environment: - SMTP_PASS=${SMTP_PASS:?SMTP_PASS must be set} - DB_HOST=${DB_HOST:-localhost}# ofelia.ini — Ofelia handles simple substitution smtp-password = ${SMTP_PASS}
Basic Structure
[global]
# Global configuration options
[job-TYPE "NAME"]
# Job-specific configuration
# TYPE: exec, run, local, service, compose
# NAME: Unique job identifier
Global Settings
[global]
# Docker Configuration
docker-host = unix:///var/run/docker.sock
docker-poll-interval = 30s
docker-events = true
allow-host-jobs-from-labels = false
default-user = nobody # Default for exec/run/service; empty uses container default
# How label-defined job-exec names are scoped (see "Cross-Container Job
# References" below). Default `service` is collision-prone when one daemon
# watches several Compose projects that reuse a service name; use `container`
# or `container-service` to keep them distinct.
# service - {service}.{job} (default; falls back to container name)
# container - {container}.{job} (collision-safe per Docker daemon)
# container-service - {container}.{service}.{job} (descriptive + collision-safe)
job-exec-label-scope = service
# Notification deduplication: suppress duplicate error notifications (Slack, email,
# save) for jobs that fail repeatedly with the same error. Set to 0 (default) to
# disable deduplication and emit every notification. Accepts any Go duration
# (`5m`, `1h`, `30s`).
notification-cooldown = 0
# Notification Settings (deprecated Slack middleware - prefer the webhook system below)
slack-webhook = https://hooks.slack.com/services/XXX/YYY/ZZZ
slack-only-on-error = true
# Email Settings
email-from = ofelia@example.com
email-to = admin@example.com
smtp-host = smtp.gmail.com
smtp-port = 587
smtp-user = ofelia@example.com
smtp-password = ${SMTP_PASSWORD} # Environment variable reference
# Skip TLS certificate verification when dialing the SMTP server. Default: false.
# WARNING: Disables MITM protection — only enable for test environments or when
# the SMTP server uses a private/internal CA that cannot be trusted system-wide.
# See docs/TROUBLESHOOTING.md for the security trade-off.
smtp-tls-skip-verify = false
# STARTTLS posture for the outbound SMTP dialer. Default: mandatory.
# Valid values:
# mandatory - Require STARTTLS; abort with an error if the server does not advertise it.
# This is the default and prevents silent cleartext fallback.
# opportunistic - Use STARTTLS when offered, silently fall back to plaintext otherwise.
# Use only with a fully trusted network path (e.g. localhost relay).
# none - Disable STARTTLS entirely; messages and credentials sent in cleartext.
# Required for some test fixtures (MailHog); intentionally insecure.
# See docs/TROUBLESHOOTING.md for the rationale and the security trade-offs.
smtp-tls-policy = mandatory
# Output Settings
save-folder = /var/log/ofelia
save-only-on-error = false
# Web UI Settings
enable-web = true
# Bind to localhost by default to avoid exposing the web UI to untrusted networks.
# Change only when you have proper network access controls in place.
web-address = 127.0.0.1:8080
# Web UI Authentication (RECOMMENDED for production)
# WARNING: Disabling auth exposes /api/* endpoints including job creation/execution.
# If auth is disabled, ensure web-address is bound to localhost or a protected interface.
web-auth-enabled = true
web-username = admin
web-password-hash = \$2a\$12$... # bcrypt hash - use 'ofelia hash-password' to generate
web-secret-key = ${WEB_SECRET_KEY} # Required for persistent sessions across restarts
web-token-expiry = 24 # hours
web-max-login-attempts = 5
# Trusted proxy CIDRs (comma-separated). Only requests originating from these
# networks will have their X-Forwarded-For / X-Real-IP headers honored when
# determining the client IP for login rate-limiting and audit logs.
# SECURITY: leave empty if Ofelia is exposed directly (no reverse proxy) — any
# entry here lets a request from that network spoof its source IP via headers.
# Set to your reverse proxy's network only (e.g. Docker bridge, k8s pod CIDR,
# load-balancer subnet). Accepts CIDRs ("172.17.0.0/16") or single IPs.
web-trusted-proxies = 172.17.0.0/16, 10.0.0.0/8
# Monitoring
enable-pprof = false
pprof-address = :6060
# Security
enable-strict-validation = false
Job Types
ExecJob - Execute in Existing Container
Runs commands inside an already-running container.
[job-exec "database-backup"]
# Required
schedule = @midnight # Cron expression or preset
container = postgres # Container name or ID
command = pg_dump mydb > /backup/db.sql
# Optional
user = postgres # User to run command as
environment = DB_NAME=mydb,BACKUP_RETENTION=7
tty = false # Allocate TTY
console-height = 24 # Initial pseudo-TTY rows (only honored when tty=true; #235)
console-width = 80 # Initial pseudo-TTY columns
delay = 5s # Delay before execution
# Middleware Configuration
slack-webhook = https://hooks.slack.com/...
slack-only-on-error = true
email-to = dba@example.com
email-subject = Database Backup Report
save-folder = /logs/backups
save-only-on-error = false
overlap = false # Prevent overlapping runs
RunJob - Execute in New Container
Creates a new container for each job execution.
[job-run "data-processor"]
# Required
schedule = 0 */6 * * * # Every 6 hours
image = myapp/processor:latest
# Optional
command = process-data --mode=batch
pull = always # always, never, if-not-present
network = backend # Docker network
user = 1000:1000 # UID:GID or username
hostname = processor-job
# Container Configuration
environment = ENV=production,LOG_LEVEL=info
volumes = /data:/data:ro,/output:/output:rw
devices = /dev/fuse:/dev/fuse
capabilities-add = SYS_ADMIN
capabilities-drop = NET_RAW
dns = 8.8.8.8,8.8.4.4
labels = job=processor,env=prod
working-dir = /app
memory = 512m
memory-swap = 1g
cpu-shares = 512
cpu-quota = 50000
# Cleanup
delete = true # Delete container after execution
delete-timeout = 30s # Timeout for deletion
stop-signal = SIGINT # Signal sent at stop time (#234); empty = image's STOPSIGNAL or SIGTERM
stop-timeout = 30s # Grace period before SIGKILL in cleanupOnDeadline (#234); 0 = legacy 10s default
# Restart Policy
restart-on-failure = 3 # Max restart attempts
LocalJob - Execute on Host
Runs commands directly on the host machine.
[job-local "system-cleanup"]
# Required
schedule = @daily
command = /usr/local/bin/cleanup.sh
# Optional
user = maintenance # System user
dir = /var/maintenance # Working directory
environment = CLEANUP_DAYS=30,LOG_FILE=/var/log/cleanup.log
# Security Warning: LocalJobs run with host privileges
# Not available from Docker labels unless explicitly allowed
ServiceJob - Docker Swarm Service
Deploys as a Docker Swarm service (requires Swarm mode).
[job-service-run "distributed-task"]
schedule = @hourly
image = myapp/worker:latest
command = run-distributed-task
network = swarm_network
environment = DB_HOST=postgres
environment = DB_PORT=5432
hostname = worker-1
dir = /opt/app
user = appuser
delete = true
max-runtime = 1h
ComposeJob - Docker Compose Operations
Manages Docker Compose projects.
[job-compose "stack-restart"]
# Required
schedule = 0 4 * * * # 4 AM daily
project = myapp # Project name
command = restart # Compose command
# Optional
service = web # Specific service
timeout = 300s # Operation timeout
dir = /opt/compose/myapp # Working directory with docker-compose.yml
environment = COMPOSE_PROJECT_NAME=myapp
Docker Labels Configuration
Configure jobs using container labels:
Basic Label Format
labels:
# Enable Ofelia for this container
ofelia.enabled: "true"
# Job configuration: ofelia.JOB-TYPE.JOB-NAME.PROPERTY
ofelia.job-exec.backup.schedule: "@midnight"
ofelia.job-exec.backup.command: "backup.sh"
ofelia.job-exec.backup.user: "root"
Webhook Labels
Define named webhooks using Docker labels on the service container (ofelia.service: "true"):
labels:
ofelia.enabled: "true"
ofelia.service: "true"
# Define a webhook: ofelia.webhook.NAME.PROPERTY
ofelia.webhook.slack-alerts.preset: slack
ofelia.webhook.slack-alerts.id: "T00000000/B00000000000"
ofelia.webhook.slack-alerts.secret: "XXXXXXXXXXXXXXXXXXXXXXXX"
ofelia.webhook.slack-alerts.trigger: error
# Global webhook keys exposed via labels. SSRF-sensitive globals
# (allowed-hosts, allow-remote-presets, etc.) must be set in the INI
# [global] section. See webhooks.md and #486.
ofelia.webhook-webhooks: "slack-alerts"
ofelia.webhook-preset-cache-ttl: "12h"
Assign webhooks to jobs on any container:
labels:
ofelia.enabled: "true"
ofelia.job-exec.backup.schedule: "@daily"
ofelia.job-exec.backup.command: "pg_dump mydb"
ofelia.job-exec.backup.webhooks: "slack-alerts"
See Webhook Documentation for all parameters and presets.
Complete Example
version: '3.8'
services:
database:
image: postgres:15
labels:
# Enable Ofelia
ofelia.enabled: "true"
# Backup job
ofelia.job-exec.db-backup.schedule: "0 2 * * *"
ofelia.job-exec.db-backup.command: "pg_dump -U postgres mydb > /backup/db.sql"
ofelia.job-exec.db-backup.user: "postgres"
ofelia.job-exec.db-backup.environment: "PGPASSWORD=secret"
# Maintenance job
ofelia.job-exec.db-vacuum.schedule: "@weekly"
ofelia.job-exec.db-vacuum.command: "vacuumdb --all --analyze"
# Health check job
ofelia.job-exec.db-health.schedule: "@every 5m"
ofelia.job-exec.db-health.command: "pg_isready -U postgres"
ofelia.job-exec.db-health.slack-only-on-error: "true"
app:
image: myapp:latest
labels:
ofelia.enabled: "true"
# Cache warming
ofelia.job-exec.cache-warm.schedule: "0 */4 * * *"
ofelia.job-exec.cache-warm.command: "php artisan cache:warm"
# Queue processing
ofelia.job-exec.queue-process.schedule: "@every 1m"
ofelia.job-exec.queue-process.command: "php artisan queue:work --stop-when-empty"
Global Settings in Docker Compose
Docker labels configure jobs and webhooks, but not global settings like logging or output storage. For global configuration in Docker Compose, use environment variables on the Ofelia container:
version: '3.8'
services:
ofelia:
image: ghcr.io/netresearch/ofelia:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./logs:/var/log/ofelia # Mount for save-folder
environment:
# Logging Configuration
- OFELIA_LOG_LEVEL=debug # DEBUG, INFO, WARNING, ERROR
# Docker Settings
- OFELIA_DOCKER_EVENTS=true # Use events instead of polling
- OFELIA_POLL_INTERVAL=30s # Poll interval for labels
- OFELIA_DOCKER_FILTER=label=monitored=true # Filter containers
# Web UI
- OFELIA_ENABLE_WEB=true
- OFELIA_WEB_ADDRESS=:8080
# Performance Profiling
- OFELIA_ENABLE_PPROF=false
labels:
ofelia.enabled: "true"
# Job-run labels go on Ofelia container
ofelia.job-run.cleanup.schedule: "@daily"
ofelia.job-run.cleanup.image: "alpine:latest"
ofelia.job-run.cleanup.command: "rm -rf /tmp/*"
Configuration by Method:
| Setting Type | Docker Labels | Environment Variables | INI Config |
|---|---|---|---|
| Job schedules | ✅ Yes | ❌ No | ✅ Yes |
| Job commands | ✅ Yes | ❌ No | ✅ Yes |
| Log level | ❌ No | ✅ OFELIA_LOG_LEVEL | ✅ log-level |
| Save folder | ❌ No | ❌ No | ✅ save-folder |
| Save only on error | ❌ No | ❌ No | ✅ save-only-on-error |
| Docker host | ❌ No | ❌ No | ✅ docker-host |
| Web UI | ❌ No | ✅ OFELIA_ENABLE_WEB | ✅ enable-web |
| Webhook definitions | ✅ ofelia.webhook.NAME.* | ❌ No | ✅ [webhook "NAME"] |
| Webhook assignment | ✅ ofelia.job-*.NAME.webhooks | ❌ No | ✅ webhooks in job section |
Note: For output capture (save-folder, save-only-on-error), use an INI configuration file. These settings require file system paths and are not available via environment variables or labels.
Schedule Expressions
Cron Format
Standard cron expressions with seconds (optional):
┌───────────── second (0-59) [OPTIONAL]
│ ┌───────────── minute (0-59)
│ │ ┌───────────── hour (0-23)
│ │ │ ┌───────────── day of month (1-31)
│ │ │ │ ┌───────────── month (1-12)
│ │ │ │ │ ┌───────────── day of week (0-7, 0 and 7 are Sunday)
│ │ │ │ │ │
│ │ │ │ │ │
* * * * * *
Preset Schedules
@yearly # Run once a year (0 0 1 1 *)
@annually # Same as @yearly
@monthly # Run once a month (0 0 1 * *)
@weekly # Run once a week (0 0 * * 0)
@daily # Run once a day (0 0 * * *)
@midnight # Same as @daily
@hourly # Run once an hour (0 * * * *)
@every 5m # Run every 5 minutes
@every 1h30m # Run every 1.5 hours
@triggered # Only run when triggered (via on-success, on-failure, or RunJob)
@manual # Alias for @triggered
@none # Alias for @triggered
Examples
# Every 15 minutes
schedule = */15 * * * *
# Monday to Friday at 9 AM
schedule = 0 9 * * 1-5
# First day of month at 2:30 AM
schedule = 30 2 1 * *
# Every 30 seconds
schedule = */30 * * * * *
# Complex: Every 2 hours between 8 AM and 6 PM on weekdays
schedule = 0 8-18/2 * * 1-5
Environment Variables
Override configuration using environment variables:
# Global settings
OFELIA_DOCKER_HOST=tcp://docker:2376
OFELIA_DOCKER_POLL_INTERVAL=1m
OFELIA_SLACK_URL=https://hooks.slack.com/...
OFELIA_JWT_SECRET=my-secret-key
# Job-specific (format: OFELIA_JOB_TYPE_NAME_PROPERTY)
OFELIA_JOB_EXEC_BACKUP_SCHEDULE=@hourly
OFELIA_JOB_RUN_CLEANUP_IMAGE=alpine:3.18
Command-line Flags
ofelia daemon \
--config=/etc/ofelia/config.ini \
--docker-host=unix:///var/run/docker.sock \
--docker-poll-interval=30s \
--docker-events \
--enable-web \
--web-address=:8080 \
--enable-pprof \
--pprof-address=:6060 \
--log-level=debug
Middleware Configuration
Webhook Notifications (Recommended)
The new webhook notification system supports multiple services (Slack, Discord, Teams, Matrix, ntfy, Pushover, PagerDuty, Gotify) with named webhooks that can be assigned to specific jobs.
[global]
webhook-allow-remote-presets = false
[webhook "slack-alerts"]
preset = slack
id = T00000000/B00000000000
secret = XXXXXXXXXXXXXXXXXXXXXXXX
trigger = error
[webhook "discord-notify"]
preset = discord
id = 1234567890123456789
secret = abcdefghijklmnopqrstuvwxyz1234567890ABCDEF
trigger = always
[job-exec "important-task"]
schedule = @daily
container = worker
command = important-task.sh
webhooks = slack-alerts, discord-notify
See Webhook Documentation for complete configuration options, all bundled presets, custom preset creation, and security considerations.
Slack Notifications (Deprecated)
Note: The
slack-webhookoption is deprecated. Please migrate to the new webhook notification system which provides better flexibility, multiple service support, and per-job webhook assignment.
[job-exec "important-task"]
schedule = @daily
container = worker
command = important-task.sh
# Slack settings (DEPRECATED - use [webhook "name"] sections instead)
# Only slack-webhook and slack-only-on-error are accepted by the legacy
# middleware. For channel routing, mentions, custom username/avatar, etc.,
# migrate to the webhook notification system above (preset = slack).
slack-webhook = https://hooks.slack.com/services/XXX/YYY/ZZZ
slack-only-on-error = false
Email Notifications
[job-exec "critical-job"]
schedule = @hourly
container = app
command = critical-check.sh
# Email settings
email-to = ops@example.com,alerts@example.com
email-subject = Critical Job Report
email-from = ofelia@example.com
mail-only-on-error = true
Configuration Inheritance
Notification settings support inheritance from global to job-level configuration. When a job specifies only some notification settings, the missing values are automatically inherited from the [global] section.
Inheritance Rules:
| Setting Type | Inherited Fields | Notes |
|---|---|---|
smtp-host, smtp-port, smtp-user, smtp-password, email-from, email-to, email-subject, mail-only-on-error | SMTP connection details and behavior flags are inherited | |
| Slack | slack-webhook, slack-only-on-error | Webhook URL and behavior flags are inherited |
| Save | save-folder, save-only-on-error | Save folder and behavior flags are inherited |
Example: Partial Override
[global]
# Define SMTP settings once
smtp-host = smtp.example.com
smtp-port = 587
smtp-user = notifications@example.com
smtp-password = ${SMTP_PASSWORD}
email-from = ofelia@example.com
email-to = ops@example.com
[job-exec "backup"]
schedule = @daily
container = postgres
command = pg_dump mydb > /backup/db.sql
# Only override error-only behavior - inherits all SMTP settings from global
mail-only-on-error = true
[job-exec "critical-check"]
schedule = @hourly
container = app
command = health-check.sh
# Override recipient - inherits SMTP settings from global
email-to = critical-alerts@example.com
Important Notes:
- Boolean fields (
mail-only-on-error,slack-only-on-error,save-only-on-error) are fully inherited from global config and can be overridden per-job in both directions (global true + job false, or global false + job true) - Job-level settings always take precedence over global settings when explicitly set
- To enable notifications for a job, at minimum specify
email-toorslack-webhookat either global or job level
Output Saving
[job-exec "data-export"]
schedule = @daily
container = exporter
command = export-data.sh
# Save output
save-folder = /var/log/ofelia/exports
save-only-on-error = false
# File/directory permissions for saved output (octal). Defaults are
# security-hardened: 0600 files (daemon-uid only) and 0750 folder. Widen them
# when a non-root operator or shared group must read the logs on the host.
# Accepts 0644, 0o644 or 644. Like save-folder, these are not in the global
# Docker-label allow-list, so a container cannot change the daemon-wide default.
# save-folder-mode applies only when Ofelia creates the folder; an existing
# folder keeps its current permissions (mkdir -p semantics).
save-mode = 0644 # default 0600
save-folder-mode = 0755 # default 0750
# History restoration on startup (set on the [global] section)
# - restore-history: enable/disable replaying saved executions on daemon start
# (default: enabled when save-folder is set)
# - restore-history-max-age: only replay executions newer than this duration
# (default: 24h)
restore-history = true
restore-history-max-age = 48h
Overlap Prevention
[job-exec "long-running"]
schedule = */10 * * * *
container = worker
command = long-task.sh
# Prevent overlapping runs
overlap = false
Job Dependencies
Ofelia supports job dependencies to create workflows where jobs can depend on other jobs, or trigger other jobs on success or failure.
Dependency Configuration
Define job execution order and conditional triggers:
[job-exec "init-database"]
schedule = @daily
container = postgres
command = /scripts/init-db.sh
[job-exec "backup-database"]
schedule = @daily
container = postgres
command = /scripts/backup.sh
# Wait for init-database to complete first
depends-on = init-database
[job-exec "process-data"]
schedule = @daily
container = worker
command = /scripts/process.sh
# Multiple dependencies (use multiple lines)
depends-on = init-database
depends-on = backup-database
# Trigger these jobs on success
on-success = notify-complete
# Trigger these jobs on failure
on-failure = alert-ops
[job-exec "notify-complete"]
schedule = @triggered
container = notifier
command = /scripts/success-notify.sh
[job-exec "alert-ops"]
schedule = @triggered
container = notifier
command = /scripts/failure-alert.sh
Note: Jobs triggered only via
on-successoron-failureshould use@triggered(or aliases@manual/@none). These jobs are registered but not scheduled in cron - they only run when triggered by another job or manually.
Dependency Options
| Option | Description | Example |
|---|---|---|
depends-on | Jobs that must complete successfully before this job runs | depends-on = init-job |
on-success | Jobs to trigger when this job completes successfully | on-success = cleanup-job |
on-failure | Jobs to trigger when this job fails | on-failure = alert-job |
Docker Labels Syntax
version: '3.8'
services:
worker:
image: myapp:latest
labels:
ofelia.enabled: "true"
# Main processing job
ofelia.job-exec.process.schedule: "@hourly"
ofelia.job-exec.process.command: "process.sh"
ofelia.job-exec.process.depends-on: "setup"
ofelia.job-exec.process.on-success: "cleanup"
ofelia.job-exec.process.on-failure: "alert"
# Setup job (dependency)
ofelia.job-exec.setup.schedule: "@hourly"
ofelia.job-exec.setup.command: "setup.sh"
# Cleanup job (triggered on success)
ofelia.job-exec.cleanup.schedule: "@triggered"
ofelia.job-exec.cleanup.command: "cleanup.sh"
# Alert job (triggered on failure)
ofelia.job-exec.alert.schedule: "@triggered"
ofelia.job-exec.alert.command: "alert.sh"
Cross-Container Job References (Docker Compose)
When using Docker Compose, jobs are automatically named using the service name from docker-compose.yml, not the generated container name. This enables intuitive cross-container job references:
version: '3.8'
services:
database:
image: postgres:15
labels:
ofelia.enabled: "true"
ofelia.job-exec.backup.schedule: "@daily"
ofelia.job-exec.backup.command: "pg_dump -U postgres mydb"
ofelia.job-exec.backup.on-success: "app.notify" # Reference job on 'app' service
app:
image: myapp:latest
labels:
ofelia.enabled: "true"
ofelia.job-exec.process.schedule: "@hourly"
ofelia.job-exec.process.command: "process.sh"
ofelia.job-exec.process.depends-on: "database.backup" # Wait for database backup
ofelia.job-exec.notify.schedule: "@triggered"
ofelia.job-exec.notify.command: "notify.sh"
Jobs are named as {service}.{job-name}:
database.backup- Backup job on the database serviceapp.process- Process job on the app serviceapp.notify- Notify job on the app service
For non-Compose containers (without the com.docker.compose.service label), the container name is used instead.
Collision-safe scoping for multi-project hosts (job-exec-label-scope)
The default {service}.{job} naming is load-bearing for the cross-container references above, but it is not collision-safe when one central Ofelia daemon watches several independent Compose projects that share a service name. Two stacks deployed from the same template — e.g. acme-web and globex-web, both with Compose service web and an identical ofelia.job-exec.sync-news label — both collapse to the single key web.sync-news. Only the first container (running → newest → name order) wins; the other stack's job is dropped (see #734). Whenever this happens Ofelia logs a job-exec name collision warning naming both containers and this option, so a dropped job is never lost silently — but you still need to pick a collision-safe scope below to actually run both jobs.
The [global] option job-exec-label-scope controls how job-exec names are scoped:
| Value | Job name | Notes |
|---|---|---|
service (default) | web.sync-news | Compose service name (falls back to container name). Required for cross-container depends-on/on-success references. Collides across projects that reuse a service name. |
container | acme-web.sync-news | Container name, which Docker guarantees unique per daemon. Collision-safe. |
container-service | acme-web.web.sync-news | Container and service name — descriptive and collision-safe. Falls back to {container}.{job} for non-Compose containers. |
[global]
# One daemon serving acme-web and globex-web (both Compose service "web"):
job-exec-label-scope = container
This is a daemon-wide setting configured in the Ofelia [global] section (INI), not via container labels — a single container must not redefine how every other container's jobs are named. When you switch away from service, update any cross-container depends-on/on-success/on-failure references to use the new scoped names (e.g. acme-database.backup instead of database.backup).
Important Notes
- Circular dependencies are detected - Ofelia will reject configurations with circular dependency chains
- Dependencies must exist - Referenced jobs must be defined in the configuration
- All job types supported - Dependencies work across all job types (exec, run, local, service, compose)
- Multiple dependencies - Use multiple
depends-onlines in INI format to specify multiple dependencies - Service name precedence - Docker Compose service names take precedence over container names for job naming
Security Considerations
Restricting Host Jobs
[global]
# Prevent LocalJobs from Docker labels
allow-host-jobs-from-labels = false
Web UI Authentication
Ofelia's web UI supports optional authentication to protect API endpoints:
[global]
# Enable authentication
web-auth-enabled = true
web-username = admin
web-password-hash = \$2a\$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.F3V7Y8GdDmz7hG
# Token configuration
web-secret-key = ${WEB_SECRET_KEY} # Auto-generated if not set
web-token-expiry = 24 # Hours
web-max-login-attempts = 5 # Per minute per IP
Generating a password hash:
# Using htpasswd (Apache utils)
htpasswd -bnBC 12 "" 'your-password' | tr -d ':\n'
# Using Python
python3 -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt(12)).decode())"
Authentication flow:
- POST
/api/loginwith{"username":"...", "password":"..."} - Receive token in response and
auth_tokencookie - Include token as
Authorization: Bearer <token>or cookie for subsequent requests - POST
/api/logoutto invalidate token
Security features:
- bcrypt password hashing (cost 12)
- Rate limiting per IP
- CSRF token protection
- Secure cookie settings (HttpOnly, SameSite=Strict)
- Constant-time credential comparison
Input Validation
Ofelia provides two levels of input validation:
Basic Validation (Default)
- Cron expression validation
- Required field checks
- Docker image name format validation
Strict Validation (Opt-in)
Enable strict validation for security-conscious environments:
[global]
enable-strict-validation = true
When enabled, strict validation provides:
- Command injection prevention (blocks shell metacharacters)
- Path traversal protection (blocks
../patterns) - Network restriction (blocks private IP ranges)
- File extension filtering (blocks
.sh,.exe, etc.) - Tool restrictions (blocks
wget,curl,rsync, etc.)
Default: false (disabled)
When to enable:
- Multi-tenant environments with untrusted users
- Strict security compliance requirements (SOC2, PCI-DSS)
- Public-facing job scheduling systems
- Highly regulated environments
When to keep disabled:
- Infrastructure automation requiring shell scripts
- Backup operations using
rsync,wget,curl - Jobs accessing private networks (192.168., 10., 172.*)
- Airgapped/restricted environments with
.localdomains - Most production deployments (Ofelia runs commands inside isolated containers)
Best Practices
-
Use environment variables for secrets
smtp-password = ${SMTP_PASSWORD} jwt-secret = ${JWT_SECRET} -
Enable Docker events for real-time updates
docker-events = true -
Set appropriate job timeouts
delete-timeout = 30s -
Use overlap prevention for long-running jobs
overlap = false -
Configure appropriate resource limits
memory = 512m cpu-shares = 512 -
Use save-only-on-error for debugging
save-only-on-error = true -
Implement health checks
[job-exec "health-check"] schedule = @every 5m container = app command = health-check.sh slack-only-on-error = true
Validation
Validate configuration before deployment:
# Validate INI file
ofelia validate --config=/etc/ofelia/config.ini
# Test specific job
ofelia test --config=/etc/ofelia/config.ini --job=backup
# Dry run (show what would be executed)
ofelia daemon --config=/etc/ofelia/config.ini --dry-run
See also: API Documentation | CLI Package | Project Index