Set up automated cron job deployment and monitoring across multiple servers using Ansible playbooks with systemd timers for reliable task scheduling and centralized logging.
Prerequisites
- Root access to control and target servers
- Basic understanding of SSH and Linux system administration
- Multiple servers for centralized management
What this solves
Managing cron jobs across multiple servers becomes complex when you need consistent scheduling, monitoring, and logging. This tutorial shows you how to centralize cron management using Ansible to deploy systemd timers automatically, providing better logging, dependency handling, and failure detection than traditional cron.
Step-by-step configuration
Install Ansible on the control node
Start by installing Ansible on your management server where you'll run the playbooks from.
sudo apt update
sudo apt install -y ansible python3-pip
pip3 install ansible-core
Create Ansible inventory file
Define your target servers in an inventory file for Ansible to manage.
[cron_servers]
web1.example.com ansible_host=203.0.113.10
web2.example.com ansible_host=203.0.113.11
db1.example.com ansible_host=203.0.113.12
[all:vars]
ansible_user=ansible
ansible_ssh_private_key_file=~/.ssh/ansible_key
Configure SSH key authentication
Set up passwordless SSH access from your Ansible control node to all managed servers.
ssh-keygen -t ed25519 -f ~/.ssh/ansible_key -N ""
ssh-copy-id -i ~/.ssh/ansible_key.pub ansible@203.0.113.10
ssh-copy-id -i ~/.ssh/ansible_key.pub ansible@203.0.113.11
ssh-copy-id -i ~/.ssh/ansible_key.pub ansible@203.0.113.12
Create the cron management playbook directory
Organize your Ansible playbooks with a proper directory structure for templates and tasks.
mkdir -p ~/ansible-cron/{playbooks,templates,group_vars}
cd ~/ansible-cron
Create systemd timer template
This template generates systemd timer files for scheduled tasks with better logging than cron.
[Unit]
Description={{ item.description | default('Scheduled task') }}
Requires={{ item.name }}.service
[Timer]
OnCalendar={{ item.schedule }}
Persistent=true
RandomizedDelaySec={{ item.randomized_delay | default('0') }}
[Install]
WantedBy=timers.target
Create systemd service template
This template defines the actual commands that run when the timer triggers.
[Unit]
Description={{ item.description | default('Scheduled task service') }}
After=network.target
[Service]
Type=oneshot
User={{ item.user | default('root') }}
Group={{ item.group | default('root') }}
WorkingDirectory={{ item.working_directory | default('/tmp') }}
ExecStart={{ item.command }}
StandardOutput=journal
StandardError=journal
SyslogIdentifier={{ item.name }}
{% if item.environment is defined %}
{% for env_var in item.environment %}
Environment={{ env_var }}
{% endfor %}
{% endif %}
[Install]
WantedBy=multi-user.target
Define cron jobs configuration
Create a YAML file to define all your scheduled tasks with their parameters.
---
cron_jobs:
- name: backup-database
description: "Daily database backup"
command: "/usr/local/bin/backup-db.sh"
schedule: "daily"
user: "backup"
group: "backup"
working_directory: "/var/backups"
environment:
- "BACKUP_RETENTION=7"
- "BACKUP_COMPRESSION=gzip"
- name: log-cleanup
description: "Weekly log file cleanup"
command: "/usr/local/bin/cleanup-logs.sh"
schedule: "weekly"
user: "root"
randomized_delay: "1h"
- name: system-update-check
description: "Check for system updates every 4 hours"
command: "/usr/local/bin/check-updates.sh"
schedule: "--* 00,04,08,12,16,20:00:00"
user: "root"
- name: web-health-check
description: "Monitor web application health every 5 minutes"
command: "/usr/local/bin/health-check.sh"
schedule: "*:0/5"
user: "monitor"
group: "monitor"
working_directory: "/opt/monitoring"
Create the main playbook
This playbook deploys systemd timers and services to all target servers based on your configuration.
---
- name: Deploy centralized cron management with systemd timers
hosts: cron_servers
become: yes
tasks:
- name: Create systemd service files
template:
src: systemd-service.j2
dest: "/etc/systemd/system/{{ item.name }}.service"
mode: '644'
owner: root
group: root
loop: "{{ cron_jobs }}"
notify:
- Reload systemd
- Restart services
- name: Create systemd timer files
template:
src: systemd-timer.j2
dest: "/etc/systemd/system/{{ item.name }}.timer"
mode: '644'
owner: root
group: root
loop: "{{ cron_jobs }}"
notify:
- Reload systemd
- Restart timers
- name: Create log directory for scheduled tasks
file:
path: /var/log/scheduled-tasks
state: directory
mode: '755'
owner: root
group: root
- name: Configure rsyslog for scheduled task logging
copy:
content: |
# Log scheduled tasks to separate file
if $programname startswith 'backup-database' then /var/log/scheduled-tasks/backup.log
if $programname startswith 'log-cleanup' then /var/log/scheduled-tasks/cleanup.log
if $programname startswith 'system-update-check' then /var/log/scheduled-tasks/updates.log
if $programname startswith 'web-health-check' then /var/log/scheduled-tasks/health.log
& stop
dest: /etc/rsyslog.d/50-scheduled-tasks.conf
mode: '644'
notify:
- Restart rsyslog
- name: Create users for scheduled tasks
user:
name: "{{ item.user }}"
system: yes
shell: /bin/false
home: /var/lib/{{ item.user }}
create_home: yes
loop: "{{ cron_jobs }}"
when: item.user != 'root'
handlers:
- name: Reload systemd
systemd:
daemon_reload: yes
- name: Restart services
systemd:
name: "{{ item.name }}.service"
state: restarted
enabled: yes
loop: "{{ cron_jobs }}"
- name: Restart timers
systemd:
name: "{{ item.name }}.timer"
state: restarted
enabled: yes
loop: "{{ cron_jobs }}"
- name: Restart rsyslog
service:
name: rsyslog
state: restarted
Create monitoring playbook
This playbook helps you monitor the status of all scheduled tasks across your infrastructure.
---
- name: Monitor systemd timers and services
hosts: cron_servers
become: yes
tasks:
- name: Check timer status
command: systemctl is-active {{ item.name }}.timer
register: timer_status
loop: "{{ cron_jobs }}"
failed_when: false
changed_when: false
- name: Check service status
command: systemctl is-enabled {{ item.name }}.timer
register: service_enabled
loop: "{{ cron_jobs }}"
failed_when: false
changed_when: false
- name: Get last run information
command: systemctl show {{ item.name }}.timer --property=LastTriggerUSec
register: last_run
loop: "{{ cron_jobs }}"
changed_when: false
- name: Display timer status
debug:
msg: |
Timer: {{ item.item.name }}
Status: {{ item.stdout }}
Last run: {{ last_run.results[ansible_loop.index0].stdout.split('=')[1] }}
loop: "{{ timer_status.results }}"
loop_control:
extended: yes
Deploy the configuration
Run the Ansible playbook to deploy systemd timers to all your servers.
cd ~/ansible-cron
ansible-playbook -i /etc/ansible/hosts playbooks/deploy-cron.yml
ansible-playbook -i /etc/ansible/hosts playbooks/monitor-cron.yml
Set up log rotation for scheduled tasks
Configure logrotate to manage log files from your scheduled tasks.
/var/log/scheduled-tasks/*.log {
daily
missingok
rotate 30
compress
delaycompress
copytruncate
notifempty
sharedscripts
postrotate
/bin/kill -HUP cat /var/run/rsyslogd.pid 2>/dev/null 2>/dev/null || true
endscript
}
Add logrotate configuration to playbook
Update your main playbook to include log rotation configuration.
cat >> ~/ansible-cron/playbooks/deploy-cron.yml << 'EOF'
- name: Configure log rotation for scheduled tasks
template:
src: logrotate-scheduled.j2
dest: /etc/logrotate.d/scheduled-tasks
mode: '644'
owner: root
group: root
EOF
Configure centralized monitoring
Create status reporting playbook
Generate consolidated reports on all scheduled task execution across your infrastructure.
---
- name: Generate cron status report
hosts: cron_servers
become: yes
gather_facts: yes
tasks:
- name: Get systemd timer list
shell: systemctl list-timers --all --no-pager --plain | grep -E '(backup-database|log-cleanup|system-update-check|web-health-check)'
register: timer_list
failed_when: false
changed_when: false
- name: Check for failed services in last 24 hours
shell: journalctl --since "24 hours ago" --grep "Failed to start" | grep -E '(backup-database|log-cleanup|system-update-check|web-health-check)'
register: failed_services
failed_when: false
changed_when: false
- name: Generate status report
copy:
content: |
Server: {{ inventory_hostname }}
Date: {{ ansible_date_time.iso8601 }}
Active Timers:
{{ timer_list.stdout }}
Failed Services (24h):
{{ failed_services.stdout | default('No failures') }}
System Load: {{ ansible_loadavg }}
Memory Usage: {{ ansible_memory_mb.real.used }} MB / {{ ansible_memory_mb.real.total }} MB
dest: "/tmp/cron-status-{{ inventory_hostname }}.txt"
- name: Fetch status reports
fetch:
src: "/tmp/cron-status-{{ inventory_hostname }}.txt"
dest: "./reports/"
flat: yes
Create alert configuration for failed tasks
Set up email alerts when scheduled tasks fail using systemd's OnFailure directive.
[Unit]
Description=Send alert for failed {{ item.name }}
[Service]
Type=oneshot
ExecStart=/usr/local/bin/send-failure-alert.sh "{{ item.name }}" "{{ item.description }}"
User=root
Verify your setup
# Check Ansible can reach all hosts
ansible all -i /etc/ansible/hosts -m ping
View active timers on all servers
ansible cron_servers -i /etc/ansible/hosts -b -m shell -a "systemctl list-timers --active"
Check logs for specific service
ansible web1.example.com -i /etc/ansible/hosts -b -m shell -a "journalctl -u backup-database.service -n 10"
Generate comprehensive status report
ansible-playbook -i /etc/ansible/hosts playbooks/status-report.yml
ls -la reports/
Advanced configuration options
| Configuration | Purpose | Example |
|---|---|---|
| RandomizedDelaySec | Prevent all servers from running tasks simultaneously | RandomizedDelaySec=300 |
| OnFailure | Define actions when service fails | OnFailure=failure-alert@%i.service |
| Persistent | Run missed tasks after system boot | Persistent=true |
| AccuracySec | Timer accuracy for resource optimization | AccuracySec=5min |
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| Timer shows inactive | Timer not enabled | systemctl enable --now timer-name.timer |
| Service fails immediately | Command path not found | Use absolute paths in ExecStart |
| Ansible playbook fails | SSH key authentication issues | Check ssh -i ~/.ssh/ansible_key ansible@host |
| Logs not appearing | Rsyslog not restarted | systemctl restart rsyslog |
| Permission denied errors | Wrong user in service file | Set correct User= and Group= in service template |
| Timer runs too frequently | Invalid OnCalendar format | Test with systemd-analyze calendar "schedule" |
Next steps
- Set up centralized log aggregation with rsyslog and logrotate for security events
- Configure Ansible Vault for secret management and encryption
- Configure Prometheus alerting rules for cgroup metrics monitoring
- Configure Ansible dynamic inventory for AWS, Azure, and GCP
- Implement Linux resource quotas with systemd and automated enforcement
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Global variables
SCRIPT_DIR="/opt/ansible-cron"
ANSIBLE_USER="ansible"
INVENTORY_FILE=""
SSH_KEY_PATH=""
# Usage function
usage() {
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " -u USERNAME Ansible user (default: ansible)"
echo " -i INVENTORY Path to inventory file"
echo " -k SSH_KEY Path to SSH private key"
echo " -h Show this help"
exit 1
}
# Parse arguments
while getopts "u:i:k:h" opt; do
case $opt in
u) ANSIBLE_USER="$OPTARG" ;;
i) INVENTORY_FILE="$OPTARG" ;;
k) SSH_KEY_PATH="$OPTARG" ;;
h) usage ;;
*) usage ;;
esac
done
# Logging functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Cleanup function
cleanup() {
log_error "Installation failed. Cleaning up..."
if [[ -d "$SCRIPT_DIR" ]]; then
rm -rf "$SCRIPT_DIR"
fi
exit 1
}
trap cleanup ERR
# Check if running as root or with sudo
check_privileges() {
if [[ $EUID -ne 0 ]]; then
log_error "This script must be run as root or with sudo"
exit 1
fi
}
# Detect distribution
detect_distro() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"
PKG_INSTALL="apt install -y"
PKG_UPDATE="apt update"
EPEL_NEEDED=false
;;
almalinux|rocky|centos|rhel|ol)
PKG_MGR="dnf"
PKG_INSTALL="dnf install -y"
PKG_UPDATE="dnf makecache"
EPEL_NEEDED=true
;;
fedora)
PKG_MGR="dnf"
PKG_INSTALL="dnf install -y"
PKG_UPDATE="dnf makecache"
EPEL_NEEDED=false
;;
amzn)
PKG_MGR="yum"
PKG_INSTALL="yum install -y"
PKG_UPDATE="yum makecache"
EPEL_NEEDED=true
;;
*)
log_error "Unsupported distribution: $ID"
exit 1
;;
esac
export PKG_MGR PKG_INSTALL PKG_UPDATE EPEL_NEEDED
log_info "Detected distribution: $PRETTY_NAME"
else
log_error "Cannot detect distribution"
exit 1
fi
}
# Install packages
install_packages() {
log_info "[1/7] Installing required packages..."
$PKG_UPDATE
if [[ "$EPEL_NEEDED" == "true" ]]; then
if [[ "$PKG_MGR" == "dnf" ]]; then
$PKG_INSTALL epel-release
elif [[ "$PKG_MGR" == "yum" ]]; then
$PKG_INSTALL epel-release
fi
fi
case "$PKG_MGR" in
apt)
$PKG_INSTALL ansible python3-pip openssh-client
;;
dnf|yum)
$PKG_INSTALL ansible python3-pip openssh-clients
;;
esac
pip3 install --upgrade ansible-core
log_info "Packages installed successfully"
}
# Create directory structure
create_directories() {
log_info "[2/7] Creating directory structure..."
mkdir -p "$SCRIPT_DIR"/{playbooks,templates,group_vars,inventory}
chown -R root:root "$SCRIPT_DIR"
chmod 755 "$SCRIPT_DIR"
find "$SCRIPT_DIR" -type d -exec chmod 755 {} \;
log_info "Directory structure created"
}
# Create systemd timer template
create_timer_template() {
log_info "[3/7] Creating systemd timer template..."
cat > "$SCRIPT_DIR/templates/systemd-timer.j2" << 'EOF'
[Unit]
Description={{ item.description | default('Scheduled task') }}
Requires={{ item.name }}.service
[Timer]
OnCalendar={{ item.schedule }}
Persistent=true
RandomizedDelaySec={{ item.randomized_delay | default('0') }}
[Install]
WantedBy=timers.target
EOF
chmod 644 "$SCRIPT_DIR/templates/systemd-timer.j2"
log_info "Timer template created"
}
# Create systemd service template
create_service_template() {
log_info "[4/7] Creating systemd service template..."
cat > "$SCRIPT_DIR/templates/systemd-service.j2" << 'EOF'
[Unit]
Description={{ item.description | default('Scheduled task service') }}
After=network.target
[Service]
Type=oneshot
User={{ item.user | default('root') }}
Group={{ item.group | default('root') }}
WorkingDirectory={{ item.working_directory | default('/tmp') }}
ExecStart={{ item.command }}
StandardOutput=journal
StandardError=journal
SyslogIdentifier={{ item.name }}
{% if item.environment is defined %}
{% for env_var in item.environment %}
Environment={{ env_var }}
{% endfor %}
{% endif %}
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "$SCRIPT_DIR/templates/systemd-service.j2"
log_info "Service template created"
}
# Create sample configuration
create_sample_config() {
log_info "[5/7] Creating sample configuration..."
cat > "$SCRIPT_DIR/group_vars/all.yml" << 'EOF'
---
cron_jobs:
- name: backup-database
description: "Daily database backup"
command: "/usr/local/bin/backup-db.sh"
schedule: "daily"
user: "backup"
group: "backup"
working_directory: "/var/backups"
environment:
- "BACKUP_RETENTION=7"
- "BACKUP_COMPRESSION=gzip"
- name: log-cleanup
description: "Weekly log file cleanup"
command: "/usr/local/bin/cleanup-logs.sh"
schedule: "weekly"
user: "root"
randomized_delay: "1h"
- name: system-update-check
description: "Check for system updates every 4 hours"
command: "/usr/local/bin/check-updates.sh"
schedule: "*-*-* 00,04,08,12,16,20:00:00"
user: "root"
- name: web-health-check
description: "Monitor web application health every 5 minutes"
command: "/usr/local/bin/health-check.sh"
schedule: "*:0/5"
user: "monitor"
group: "monitor"
working_directory: "/opt/monitoring"
EOF
chmod 644 "$SCRIPT_DIR/group_vars/all.yml"
log_info "Sample configuration created"
}
# Create main playbook
create_playbook() {
log_info "[6/7] Creating main Ansible playbook..."
cat > "$SCRIPT_DIR/playbooks/deploy-cron.yml" << 'EOF'
---
- name: Deploy centralized cron management with systemd timers
hosts: cron_servers
become: yes
tasks:
- name: Create systemd service files
template:
src: systemd-service.j2
dest: "/etc/systemd/system/{{ item.name }}.service"
mode: '0644'
owner: root
group: root
loop: "{{ cron_jobs }}"
notify: reload systemd
- name: Create systemd timer files
template:
src: systemd-timer.j2
dest: "/etc/systemd/system/{{ item.name }}.timer"
mode: '0644'
owner: root
group: root
loop: "{{ cron_jobs }}"
notify: reload systemd
- name: Remove old cron jobs if they exist
cron:
name: "{{ item.name }}"
state: absent
loop: "{{ cron_jobs }}"
- name: Start and enable systemd timers
systemd:
name: "{{ item.name }}.timer"
state: started
enabled: yes
daemon_reload: yes
loop: "{{ cron_jobs }}"
handlers:
- name: reload systemd
systemd:
daemon_reload: yes
EOF
chmod 644 "$SCRIPT_DIR/playbooks/deploy-cron.yml"
log_info "Main playbook created"
}
# Create sample inventory and helper scripts
create_helpers() {
log_info "[7/7] Creating helper files..."
# Sample inventory
cat > "$SCRIPT_DIR/inventory/hosts" << EOF
[cron_servers]
# Add your servers here:
# web1.example.com ansible_host=10.0.1.10
# web2.example.com ansible_host=10.0.1.11
# db1.example.com ansible_host=10.0.1.12
[all:vars]
ansible_user=${ANSIBLE_USER}
ansible_ssh_private_key_file=~/.ssh/ansible_key
EOF
# Deployment script
cat > "$SCRIPT_DIR/deploy.sh" << 'EOF'
#!/bin/bash
cd "$(dirname "$0")"
ansible-playbook -i inventory/hosts playbooks/deploy-cron.yml "$@"
EOF
# Status check script
cat > "$SCRIPT_DIR/check-timers.sh" << 'EOF'
#!/bin/bash
cd "$(dirname "$0")"
ansible cron_servers -i inventory/hosts -m shell -a "systemctl list-timers --no-pager" "$@"
EOF
chmod 755 "$SCRIPT_DIR/deploy.sh" "$SCRIPT_DIR/check-timers.sh"
chmod 644 "$SCRIPT_DIR/inventory/hosts"
log_info "Helper files created"
}
# Verify installation
verify_installation() {
log_info "Verifying installation..."
# Check Ansible installation
if ! command -v ansible >/dev/null 2>&1; then
log_error "Ansible not found in PATH"
return 1
fi
# Check directory structure
local required_files=(
"$SCRIPT_DIR/templates/systemd-timer.j2"
"$SCRIPT_DIR/templates/systemd-service.j2"
"$SCRIPT_DIR/playbooks/deploy-cron.yml"
"$SCRIPT_DIR/group_vars/all.yml"
"$SCRIPT_DIR/inventory/hosts"
"$SCRIPT_DIR/deploy.sh"
"$SCRIPT_DIR/check-timers.sh"
)
for file in "${required_files[@]}"; do
if [[ ! -f "$file" ]]; then
log_error "Required file missing: $file"
return 1
fi
done
log_info "Installation verification completed successfully"
}
# Main installation function
main() {
log_info "Starting centralized cron management installation..."
check_privileges
detect_distro
install_packages
create_directories
create_timer_template
create_service_template
create_sample_config
create_playbook
create_helpers
verify_installation
log_info "Installation completed successfully!"
echo
echo -e "${GREEN}Next steps:${NC}"
echo "1. Edit $SCRIPT_DIR/inventory/hosts to add your servers"
echo "2. Configure SSH key authentication for Ansible user"
echo "3. Customize job definitions in $SCRIPT_DIR/group_vars/all.yml"
echo "4. Run deployment: $SCRIPT_DIR/deploy.sh"
echo "5. Check timer status: $SCRIPT_DIR/check-timers.sh"
}
main "$@"
Review the script before running. Execute with: bash install.sh