Introduction

logrotate prevents log files from consuming all disk space by rotating, compressing, and deleting old logs on a schedule. While every Linux system ships with logrotate, managing configurations across dozens of servers with different applications requires automation. Ansible templates logrotate configs consistently — ensuring every server has the right rotation policy for each application.

How logrotate Works

┌─────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│  /etc/logrotate  │────►│  logrotate       │────►│  Rotated logs    │
│  .conf + .d/*   │     │  (daily cron)    │     │  .1, .2.gz ...   │
└─────────────────┘     └──────────────────┘     └──────────────────┘
  • Global config: /etc/logrotate.conf
  • App configs: /etc/logrotate.d/*.conf
  • Runs via: daily cron or systemd timer

Basic logrotate with Ansible

Deploy a logrotate Config

---
- name: Configure logrotate
  hosts: servers
  become: true
  tasks:
    - name: Install logrotate
      ansible.builtin.package:
        name: logrotate
        state: present

    - name: Configure application log rotation
      ansible.builtin.copy:
        dest: /etc/logrotate.d/myapp
        content: |
          /var/log/myapp/*.log {
              daily
              missingok
              rotate 30
              compress
              delaycompress
              notifempty
              create 0640 myapp myapp
              sharedscripts
              postrotate
                  systemctl reload myapp 2>/dev/null || true
              endscript
          }
        mode: '0644'

Template-Based Configuration

Variables

# group_vars/webservers/logrotate.yml
logrotate_configs:
  - name: nginx
    paths:
      - /var/log/nginx/*.log
    frequency: daily
    rotate: 14
    compress: true
    delaycompress: true
    create: "0640 www-data adm"
    postrotate: "nginx -s reopen"

  - name: myapp
    paths:
      - /var/log/myapp/*.log
      - /var/log/myapp/workers/*.log
    frequency: daily
    rotate: 30
    compress: true
    maxsize: "100M"
    create: "0640 myapp myapp"
    postrotate: "systemctl reload myapp"

  - name: syslog-extra
    paths:
      - /var/log/cron.log
      - /var/log/auth.log
    frequency: weekly
    rotate: 52
    compress: true
    create: "0640 syslog adm"

Template

{# templates/logrotate.conf.j2 #}
{{ item.paths | join('\n') }} {
    {{ item.frequency | default('daily') }}
    missingok
    rotate {{ item.rotate | default(14) }}
{% if item.compress | default(true) %}
    compress
{% if item.delaycompress | default(true) %}
    delaycompress
{% endif %}
{% endif %}
    notifempty
{% if item.maxsize is defined %}
    maxsize {{ item.maxsize }}
{% endif %}
{% if item.minsize is defined %}
    minsize {{ item.minsize }}
{% endif %}
{% if item.maxage is defined %}
    maxage {{ item.maxage }}
{% endif %}
{% if item.create is defined %}
    create {{ item.create }}
{% endif %}
{% if item.su is defined %}
    su {{ item.su }}
{% endif %}
{% if item.dateext | default(false) %}
    dateext
    dateformat -%Y%m%d
{% endif %}
{% if item.copytruncate | default(false) %}
    copytruncate
{% endif %}
    sharedscripts
{% if item.prerotate is defined %}
    prerotate
        {{ item.prerotate }}
    endscript
{% endif %}
{% if item.postrotate is defined %}
    postrotate
        {{ item.postrotate }} 2>/dev/null || true
    endscript
{% endif %}
}

Playbook

---
- name: Deploy logrotate configurations
  hosts: all
  become: true
  tasks:
    - name: Install logrotate
      ansible.builtin.package:
        name: logrotate
        state: present

    - name: Deploy logrotate configs
      ansible.builtin.template:
        src: logrotate.conf.j2
        dest: "/etc/logrotate.d/{{ item.name }}"
        mode: '0644'
        validate: "logrotate -d %s"
      loop: "{{ logrotate_configs | default([]) }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Remove unmanaged logrotate configs
      ansible.builtin.find:
        paths: /etc/logrotate.d
        patterns: "*.conf"
        excludes: "{{ logrotate_configs | map(attribute='name') | list }}"
      register: unmanaged
      when: logrotate_cleanup | default(false)

Common Application Configs

Nginx

- name: nginx
  paths: ["/var/log/nginx/*.log"]
  frequency: daily
  rotate: 14
  compress: true
  delaycompress: true
  create: "0640 www-data adm"
  postrotate: "invoke-rc.d nginx rotate"

Docker Containers

- name: docker-containers
  paths: ["/var/lib/docker/containers/*/*.log"]
  frequency: daily
  rotate: 7
  compress: true
  copytruncate: true
  maxsize: "100M"

PostgreSQL

- name: postgresql
  paths: ["/var/log/postgresql/*.log"]
  frequency: weekly
  rotate: 12
  compress: true
  create: "0640 postgres adm"
  postrotate: "pg_ctl reload -D /var/lib/postgresql/16/main"

systemd Journal (Size Limit)

- name: Configure journald max size
  ansible.builtin.lineinfile:
    path: /etc/systemd/journald.conf
    regexp: '^#?SystemMaxUse='
    line: "SystemMaxUse=500M"
  notify: restart journald

handlers:
  - name: restart journald
    ansible.builtin.service:
      name: systemd-journald
      state: restarted

Global logrotate.conf

- name: Configure global logrotate settings
  ansible.builtin.template:
    src: logrotate-global.conf.j2
    dest: /etc/logrotate.conf
    mode: '0644'
{# templates/logrotate-global.conf.j2 #}
# Global logrotate configuration - managed by Ansible
weekly
rotate 4
create
dateext
compress

# Include app-specific configs
include /etc/logrotate.d

Validate and Test

- name: Validate all logrotate configs
  ansible.builtin.command: logrotate -d /etc/logrotate.conf
  register: validation
  changed_when: false
  failed_when: "'error' in validation.stderr"

- name: Force rotation (manual trigger)
  ansible.builtin.command: logrotate -f /etc/logrotate.conf
  when: force_rotate | default(false)

Monitor Disk Usage

- name: Check log directory sizes
  ansible.builtin.command: "du -sh {{ item }}"
  register: log_sizes
  changed_when: false
  loop:
    - /var/log
    - /var/log/nginx
    - /var/log/myapp

- name: Alert if logs exceed threshold
  ansible.builtin.assert:
    that:
      - (item.stdout.split('\t')[0] | human_to_bytes) < 5368709120  # 5GB
    fail_msg: "{{ item.item }} is {{ item.stdout.split('\t')[0] }} — exceeds 5GB!"
  loop: "{{ log_sizes.results }}"
  loop_control:
    label: "{{ item.item }}"

logrotate Directives Reference

DirectiveDescription
daily / weekly / monthlyRotation frequency
rotate NKeep N rotated files
compressgzip old logs
delaycompressCompress on next rotation (not current)
copytruncateCopy then truncate (for apps that can't reopen)
create mode owner groupCreate new file with permissions
missingokDon't error if log is missing
notifemptyDon't rotate empty files
maxsize SIZERotate when file exceeds size
minsize SIZEDon't rotate if smaller than size
maxage NDelete rotated logs older than N days
dateextUse date in rotated filename
sharedscriptsRun post/pre scripts once for all files
postrotateScript to run after rotation
prerotateScript to run before rotation
su user groupRun as specific user (for restricted dirs)

Troubleshooting

Logs Not Rotating

# Check logrotate status
cat /var/lib/logrotate/status

# Test config (dry run)
logrotate -d /etc/logrotate.d/myapp

# Force rotation
logrotate -f /etc/logrotate.d/myapp

Permission Denied

# Use su directive for restricted directories
- name: app-logs
  paths: ["/opt/myapp/logs/*.log"]
  su: "myapp myapp"
  rotate: 14
  compress: true
  copytruncate: true

Conclusion

logrotate configuration is a perfect fit for Ansible templates — define rotation policies as variables per host group, deploy them consistently across your fleet, and validate with logrotate -d. Use copytruncate for applications that can't reopen files, postrotate scripts to signal services, and maxsize for high-volume logs that can't wait for the daily cron. Every application that writes logs should have a managed logrotate config.