Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

The Problem

Every long-running service writes logs, and every log file left unmanaged eventually fills the disk. The standard Linux answer is logrotate, configured via files in /etc/logrotate.d/ — but that requires root, a package you may not be able to install (locked-down containers, restricted shared hosting, a Ruby app bundled as a self-contained deploy artifact), or coordination with a config file that lives outside your application’s control. Sometimes you just want log rotation to be a property of the application itself: call one method, and the current log never grows past a size you choose, with old generations compressed and eventually deleted automatically.

This script is a small, dependency-free log rotator you can run from cron, a systemd timer, or call directly from inside a running Ruby process before writing a new log line.

Prerequisites

  • Ruby 2.7+ (tested on Ruby 3.0.2)
  • Linux or any POSIX-like OS (uses standard file operations; the gzip step is pure Ruby via the built-in zlib library, no external gzip binary needed)
  • No gems required — optparse, zlib, and fileutils are all in Ruby’s standard library

Ruby for DevOps: Lightweight Log Rotation in Pure Ruby (A logrotate Alternative) — architecture diagram

The Code

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# log_rotate.rb
#
# A minimal, dependency-free log rotation tool -- a lightweight alternative
# to the system `logrotate` for cases where you don't have root, don't want
# to touch /etc/logrotate.d, or need rotation logic embedded directly in a
# Ruby application/deployment script.
#
# Behavior:
#   * Rotates a log file when it exceeds --max-size bytes.
#   * Keeps --keep old generations (app.log.1.gz, app.log.2.gz, ...).
#   * Compresses rotated files with gzip (via Ruby's Zlib -- no external gem).
#   * Truncates (not deletes) the live file in place so open file handles
#     held by a running process stay valid -- the same trick logrotate's
#     `copytruncate` uses.
#   * Prunes generations beyond --keep.
#
# Usage:
#   ruby log_rotate.rb --file /var/log/myapp/app.log --max-size 10485760 --keep 5
#   ruby log_rotate.rb --file app.log --max-size 1024 --keep 3 --dry-run
#
# Typical use: run from cron every few minutes, or call LogRotator.rotate!
# directly from inside a long-running Ruby service before each write batch.

require 'optparse'
require 'zlib'
require 'fileutils'

# ---------------------------------------------------------------------------
# Options
# ---------------------------------------------------------------------------
options = {
  file: nil,
  max_size: 10 * 1024 * 1024, # 10 MB
  keep: 5,
  dry_run: false
}

OptionParser.new do |opts|
  opts.banner = 'Usage: log_rotate.rb --file PATH [options]'

  opts.on('-f', '--file PATH', 'Log file to monitor and rotate (required)') do |v|
    options[:file] = v
  end

  opts.on('-s', '--max-size BYTES', Integer, 'Rotate when file exceeds this many bytes (default 10MB)') do |v|
    options[:max_size] = v
  end

  opts.on('-k', '--keep N', Integer, 'Number of rotated generations to keep (default 5)') do |v|
    options[:keep] = v
  end

  opts.on('--dry-run', 'Print what would happen without touching any files') do
    options[:dry_run] = true
  end

  opts.on('-h', '--help', 'Show this help') do
    puts opts
    exit
  end
end.parse!

if options[:file].nil?
  warn 'Error: --file is required'
  exit 1
end

# ---------------------------------------------------------------------------
# LogRotator: the reusable core, independent of the CLI options parsing above
# ---------------------------------------------------------------------------
module LogRotator
  module_function

  # Returns true if `path` is at or over `max_size` bytes.
  def needs_rotation?(path, max_size)
    return false unless File.exist?(path)

    File.size(path) >= max_size
  end

  # Compress `src` into `dest` (gzip) using Ruby's built-in Zlib -- no gems.
  def gzip_file(src, dest)
    File.open(src, 'rb') do |input|
      Zlib::GzipWriter.open(dest) do |gz|
        gz.mtime = File.mtime(src).to_i
        gz.orig_name = File.basename(src)
        IO.copy_stream(input, gz)
      end
    end
  end

  # Shift existing rotated generations up by one:
  #   app.log.2.gz -> app.log.3.gz
  #   app.log.1.gz -> app.log.2.gz
  # then any generation number greater than `keep` is deleted.
  def shift_generations(base_path, keep, dry_run: false)
    # Walk from oldest kept generation down to 1 so we don't clobber files
    # while shifting (must go in descending order).
    (keep - 1).downto(1) do |n|
      src = "#{base_path}.#{n}.gz"
      dst = "#{base_path}.#{n + 1}.gz"
      next unless File.exist?(src)

      if dry_run
        puts "  [dry-run] would move #{src} -> #{dst}"
      else
        FileUtils.mv(src, dst, force: true)
      end
    end

    # Anything beyond `keep` generations gets pruned.
    Dir.glob("#{base_path}.*.gz").each do |path|
      match = path.match(/\.(\d+)\.gz\z/)
      next unless match

      gen = match[1].to_i
      next if gen <= keep

      if dry_run
        puts "  [dry-run] would delete old generation #{path}"
      else
        FileUtils.rm_f(path)
      end
    end
  end

  # Full rotation: shift old generations, compress the current file into
  # generation 1, then truncate the live file (copytruncate semantics).
  def rotate!(path, keep:, dry_run: false)
    unless File.exist?(path)
      puts "#{path}: does not exist, nothing to rotate"
      return false
    end

    shift_generations(path, keep, dry_run: dry_run)

    gen1 = "#{path}.1.gz"
    if dry_run
      puts "  [dry-run] would compress #{path} -> #{gen1}"
      puts "  [dry-run] would truncate #{path} to 0 bytes"
      return true
    end

    gzip_file(path, gen1)

    # copytruncate: reopen in write mode and truncate to zero instead of
    # deleting+recreating, so any process with the file already open (e.g.
    # via a File/Logger instance) keeps writing to the same inode.
    File.open(path, 'w') { |f| f.truncate(0) }

    true
  end
end

# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main(options)
  path = options[:file]
  size = File.exist?(path) ? File.size(path) : 0

  puts "Checking #{path} (#{size} bytes, threshold #{options[:max_size]} bytes)"

  unless LogRotator.needs_rotation?(path, options[:max_size])
    puts 'No rotation needed.'
    return
  end

  puts "Rotating #{path} (keeping #{options[:keep]} generations)#{options[:dry_run] ? ' [DRY RUN]' : ''}..."
  LogRotator.rotate!(path, keep: options[:keep], dry_run: options[:dry_run])
  puts 'Done.'
end

main(options) if __FILE__ == $PROGRAM_NAME

Step-by-Step Walkthrough

1. Deciding when to rotate

LogRotator.needs_rotation? is deliberately trivial: does the file exist, and is it at or above max_size bytes? Keeping this check separate from the rotation logic means you can call it cheaply and often (e.g. before every log write) without paying the cost of the actual rotation unless it’s needed.

2. Shifting old generations out of the way

shift_generations renames app.log.2.gz → app.log.3.gz, then app.log.1.gz → app.log.2.gz, walking downward from keep - 1 to 1. The direction matters: if you walked upward (1, then 2, then 3) you’d overwrite app.log.2.gz with the freshly-renamed app.log.1.gz before ever reading the original app.log.2.gz, silently losing a generation. Walking downward guarantees each rename’s destination is empty before it happens. After shifting, anything found beyond the keep limit (matched with a /\.(\d+)\.gz\z/ regex against the filename) is deleted.

3. Compressing and truncating — not deleting and recreating

rotate! compresses the live file straight into app.log.1.gz using Zlib::GzipWriter, then instead of deleting the original and creating a fresh one, it reopens the same path in write mode and calls truncate(0). This is the same trick logrotate‘s copytruncate option uses: if your application (or another process) already has the log file open for writing, deleting the file out from under it would leave that process writing into an orphaned inode that no directory entry points to — the disk space never gets freed until the process restarts, and you’d never see the new log lines by tailing the filename. Truncating in place keeps the same inode, so any open file handle keeps working correctly.

4. The CLI wrapper

The bottom third of the script is just argument parsing and wiring: check whether rotation is needed, print what’s about to happen, and call LogRotator.rotate!. The --dry-run flag threads all the way down into shift_generations and rotate! so you can see exactly what a run would do — which files would move where, what would be deleted — without touching disk. Always run with --dry-run first against a real log directory before trusting it in cron.

Example Output

Tested end-to-end: create an oversized log file, rotate it three times with --keep 3, and confirm generations shift correctly and the oldest is pruned.

$ ruby log_rotate.rb --file /tmp/logtest/app.log --max-size 1024 --keep 3 --dry-run
Checking /tmp/logtest/app.log (2001 bytes, threshold 1024 bytes)
Rotating /tmp/logtest/app.log (keeping 3 generations) [DRY RUN]...
  [dry-run] would compress /tmp/logtest/app.log -> /tmp/logtest/app.log.1.gz
  [dry-run] would truncate /tmp/logtest/app.log to 0 bytes
Done.

$ ruby log_rotate.rb --file /tmp/logtest/app.log --max-size 1024 --keep 3
Checking /tmp/logtest/app.log (2001 bytes, threshold 1024 bytes)
Rotating /tmp/logtest/app.log (keeping 3 generations)...
Done.

$ ls -la /tmp/logtest
-rw-r--r-- 1 user user    0 Jul 20 11:45 app.log
-rw-r--r-- 1 user user   44 Jul 20 11:45 app.log.1.gz

# ... after 3 more rotations with --keep 3 ...
$ ls -la /tmp/logtest
-rw-r--r-- 1 user user    0 Jul 20 11:45 app.log
-rw-r--r-- 1 user user   44 Jul 20 11:45 app.log.1.gz   # newest
-rw-r--r-- 1 user user   44 Jul 20 11:45 app.log.2.gz
-rw-r--r-- 1 user user   44 Jul 20 11:45 app.log.3.gz   # oldest kept -- app.log.4.gz was pruned

After a fourth rotation, app.log.1.gz always holds the newest rotated content and anything that would become app.log.4.gz is deleted — verified directly by decompressing generation 1 and generation 3 and checking their contents matched the most-recent and oldest-kept writes, respectively.

Troubleshooting

  • “does not exist, nothing to rotate” — the --file path is wrong, or the log hasn’t been created yet by your application; this is treated as a no-op rather than an error since it’s a normal state for a freshly deployed service.
  • Rotated .gz files appear but the live file never shrinks — check that nothing else has an exclusive lock on the file; on Linux this is rare, but confirm with lsof app.log.
  • Permission denied on truncate — the process running the rotator needs write access to both the log file and its containing directory (for the rename operations during generation shifting).
  • Old generations never get pruned — verify your --keep value; a common mistake is assuming --keep 3 means “3 total copies including the live file” when it actually means 3 compressed generations plus the live file (4 total).

Extending This Script

  • Age-based rotation: add a --max-age option and rotate on “file is older than N days” in addition to size, using File.mtime.
  • Rotate a whole directory of logs matching a glob pattern in one run, rather than one file per invocation.
  • Emit a rotation event (webhook, metrics counter) so you can track rotation frequency over time — a sudden increase often means a bug is spamming your logs.
  • Integrate directly into a Logger: call LogRotator.rotate! from a custom Logger subclass’s write method so rotation happens transparently as part of normal logging, no cron needed.
  • Verify the gzip output after writing by re-opening it with Zlib::GzipReader and checking it decompresses cleanly, guarding against a rotation that produces a corrupt archive.

Leave a Reply

Your email address will not be published. Required fields are marked *