Diagram of config_templater.rb: app.conf.erb and prod.yml feed into TemplateRenderer, which produces rendered output compared by DriftDetector against the live config file via SHA-256 checksum, resulting in either an in-sync exit 0 or a drift-found exit 3 that triggers ConfigWriter.apply with a timestamped backup

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

DevOps / System Administration · Linux & Windows · Ruby 3.x

The Problem

Config files drift. Someone SSHes in during an incident, hand-edits /etc/app/app.conf to bump a timeout, and forgets to update the source of truth. Six months later nobody remembers why staging and production behave differently. Full configuration-management tools (Ansible, Chef, Puppet) solve this at scale, but for a single app’s config file, or for teams that don’t want to stand up a whole CM stack, a small Ruby script that renders a template, diffs it against what’s actually on disk, and only writes when there’s real drift — with an automatic backup — covers most of the same ground with a fraction of the moving parts.

Prerequisites

  • Ruby 3.0 or newer — everything used here (erb, yaml, digest, fileutils, optparse) is in the standard library, no gems to install
  • Works identically on Linux, macOS, and Windows since it never shells out to diff or any OS-specific tool
  • Basic familiarity with ERB template syntax (<%= variable %>)

The Code

Save this as config_templater.rb:

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# config_templater.rb
#
# Renders ERB config templates with per-environment YAML variables, detects
# drift between the rendered result and what's currently on disk, and can
# safely apply changes with an automatic timestamped backup.
#
# Works identically on Linux and Windows since it only touches the
# filesystem (no shelling out to OS-specific tools).
#
# Usage:
#   ruby config_templater.rb render  --template app.conf.erb --vars prod.yml
#   ruby config_templater.rb diff    --template app.conf.erb --vars prod.yml --target /etc/app/app.conf
#   ruby config_templater.rb apply   --template app.conf.erb --vars prod.yml --target /etc/app/app.conf
#   ruby config_templater.rb apply   --template app.conf.erb --vars prod.yml --target /etc/app/app.conf --dry-run

require "erb"
require "yaml"
require "digest"
require "fileutils"
require "optparse"
require "time"

# ---------------------------------------------------------------------------
# Renders a single ERB template file against a hash of variables.
# Uses an OpenStruct-free binding so templates can reference variables
# directly, e.g. <%= app_port %>.
# ---------------------------------------------------------------------------
class TemplateRenderer
  def self.render(template_path, vars)
    raise Errno::ENOENT, template_path unless File.exist?(template_path)

    template = File.read(template_path)
    context = BindingContext.new(vars)
    ERB.new(template, trim_mode: "-").result(context.get_binding)
  end

  # Small helper that exposes hash keys as local-like methods inside the
  # ERB binding, e.g. vars["app_port"] becomes `app_port` in the template.
  class BindingContext
    def initialize(vars)
      vars.each do |key, value|
        define_singleton_method(key) { value } if key.to_s =~ /\A[a-z_][a-zA-Z0-9_]*\z/
      end
      @vars = vars
    end

    def get_binding
      binding
    end
  end
end

# ---------------------------------------------------------------------------
# Compares rendered content to what's on disk.
# ---------------------------------------------------------------------------
module DriftDetector
  Result = Struct.new(:in_sync?, :target_exists, :diff_lines, :target_checksum, :rendered_checksum)

  def self.check(rendered, target_path)
    target_exists = File.exist?(target_path)
    current = target_exists ? File.read(target_path) : ""

    rendered_checksum = Digest::SHA256.hexdigest(rendered)
    target_checksum = Digest::SHA256.hexdigest(current)
    in_sync = rendered_checksum == target_checksum

    diff_lines = in_sync ? [] : unified_diff(current, rendered, target_path)

    Result.new(in_sync, target_exists, diff_lines, target_checksum, rendered_checksum)
  end

  # Minimal line-based diff (no external `diff` binary required, so this
  # works identically on Windows and Linux).
  def self.unified_diff(old_text, new_text, label)
    old_lines = old_text.lines
    new_lines = new_text.lines
    max = [old_lines.size, new_lines.size].max

    out = ["--- current: #{label}", "+++ rendered"]
    (0...max).each do |i|
      o = old_lines[i]
      n = new_lines[i]
      next if o == n

      out << "-#{o.chomp}" if o
      out << "+#{n.chomp}" if n
    end
    out
  end
end

# ---------------------------------------------------------------------------
# Writes the rendered file, backing up whatever was there before.
# ---------------------------------------------------------------------------
module ConfigWriter
  def self.apply(rendered, target_path, dry_run: false)
    if dry_run
      puts "[dry-run] would write #{rendered.bytesize} bytes to #{target_path}"
      return { backed_up: false, backup_path: nil }
    end

    backup_path = nil
    if File.exist?(target_path)
      timestamp = Time.now.strftime("%Y%m%d%H%M%S")
      backup_path = "#{target_path}.#{timestamp}.bak"
      FileUtils.cp(target_path, backup_path)
    end

    FileUtils.mkdir_p(File.dirname(target_path))
    File.write(target_path, rendered)

    { backed_up: !backup_path.nil?, backup_path: backup_path }
  end
end

# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_cli(argv)
  options = { dry_run: false }
  subcommand = argv.shift

  parser = OptionParser.new do |opts|
    opts.banner = "Usage: config_templater.rb [render|diff|apply] [options]"
    opts.on("--template PATH", "ERB template file") { |v| options[:template] = v }
    opts.on("--vars PATH", "YAML variables file") { |v| options[:vars] = v }
    opts.on("--target PATH", "Destination config file (diff/apply)") { |v| options[:target] = v }
    opts.on("--dry-run", "Show what apply would do without writing") { options[:dry_run] = true }
  end
  parser.parse!(argv)

  [subcommand, options, parser]
end

def load_vars(path)
  raise Errno::ENOENT, path unless File.exist?(path)

  YAML.safe_load(File.read(path)) || {}
end

if $PROGRAM_NAME == __FILE__
  subcommand, options, parser = parse_cli(ARGV)

  unless %w[render diff apply].include?(subcommand)
    puts parser
    exit 1
  end

  vars = load_vars(options.fetch(:vars))
  rendered = TemplateRenderer.render(options.fetch(:template), vars)

  case subcommand
  when "render"
    puts rendered

  when "diff"
    result = DriftDetector.check(rendered, options.fetch(:target))
    if result.in_sync?
      puts "In sync: #{options[:target]} matches the rendered template."
      exit 0
    else
      puts "DRIFT DETECTED: #{options[:target]} differs from rendered template."
      puts result.diff_lines.join("\n")
      exit 3
    end

  when "apply"
    result = DriftDetector.check(rendered, options.fetch(:target))
    if result.in_sync?
      puts "Already in sync, nothing to do: #{options[:target]}"
      exit 0
    end

    puts "Drift detected, applying changes to #{options[:target]}:"
    puts result.diff_lines.join("\n")

    outcome = ConfigWriter.apply(rendered, options[:target], dry_run: options[:dry_run])
    if outcome[:backed_up]
      puts "Backed up previous version to #{outcome[:backup_path]}"
    end
    puts "Wrote #{options[:target]}" unless options[:dry_run]
  end
end

How It Works

Diagram of config_templater.rb: app.conf.erb and prod.yml feed into TemplateRenderer, which produces rendered output compared by DriftDetector against the live config file via SHA-256 checksum, resulting in either an in-sync exit 0 or a drift-found exit 3 that triggers ConfigWriter.apply with a timestamped backup

1. Templates render through a real ERB binding

TemplateRenderer.render reads the template and hands it to ERB.new(...).result(binding). Rather than forcing you to write <%= vars["app_port"] %> everywhere, BindingContext defines a singleton method per YAML key so templates read naturally as <%= app_port %>. The regex guard on key names (/\A[a-z_][a-zA-Z0-9_]*\z/) prevents a malformed YAML key from being turned into a method call that could do something unexpected.

2. Drift detection is a checksum comparison, not a guess

DriftDetector.check hashes both the freshly rendered content and whatever's currently on disk with SHA-256 and compares the digests. This is deliberately simpler and faster than a byte-by-byte comparison, and it means "in sync" has one unambiguous definition: the file on disk is byte-for-byte what the template would produce right now. When they don't match, unified_diff produces a minimal line-based diff — not a true Myers/LCS diff like git diff, but enough to show exactly which lines changed without shelling out to the external diff binary (which isn't guaranteed to exist, especially on Windows).

3. apply never overwrites without a net

ConfigWriter.apply always copies the existing file to a timestamped .bak before writing the new version — but only when there's an existing file to back up, and only when --dry-run isn't set. This means a bad template render is always recoverable: cp app.conf.20260710105848.bak app.conf undoes it instantly, and because the backup name includes a timestamp, running apply repeatedly never clobbers an earlier backup.

4. Three subcommands map to three real workflows

render is for previewing what a template produces before you trust it near a live file. diff is read-only and built for monitoring — run it on a schedule and alert when it exits 3, meaning something drifted outside the templating pipeline. apply is the only subcommand that writes, and its --dry-run flag lets you see exactly what would change before committing to it in a change window.

Example Output

With this template:

# app.conf.erb
app_name = <%= app_name %>
port = <%= app_port %>
workers = <%= worker_count %>
log_level = <%= log_level %>

and this prod.yml:

app_name: billing-api
app_port: 8443
worker_count: 8
log_level: warn

ruby config_templater.rb render --template app.conf.erb --vars prod.yml produces:

# Managed by config_templater.rb - DO NOT EDIT BY HAND
app_name = billing-api
port = 8443
workers = 8
log_level = warn

Running diff against a target file that doesn't exist yet correctly reports full drift (exit code 3), and running apply writes the file. Running diff again immediately afterward confirms sync:

In sync: /tmp/app.conf matches the rendered template.

Simulating a manual hand-edit (echo "port = 9999" >> /tmp/app.conf) and re-running diff correctly flags the drift:

DRIFT DETECTED: /tmp/app.conf differs from rendered template.
--- current: /tmp/app.conf
+++ rendered
-port = 9999

and apply cleans it up while leaving a recovery point:

Backed up previous version to /tmp/app.conf.20260710105848.bak
Wrote /tmp/app.conf

All of the above was run end-to-end against real files on disk to confirm the render → diff → apply → re-diff → drift → re-apply cycle behaves exactly as described, including the backup file appearing with the expected timestamp suffix.

Troubleshooting

  • NameError: undefined local variable or method inside the template: your YAML key doesn't match the regex in BindingContext (for example, it starts with a number or contains a hyphen) — rename the key to a valid Ruby identifier, or extend the guard if you need broader key names.
  • diff shows the whole file changed even for a one-line edit: this is the tradeoff of the simple index-based diff — inserting or deleting a line shifts every subsequent line's index and shows up as a full rewrite from that point on. For large files where this matters, swap in the diff-lcs gem for a proper line-alignment diff.
  • Permission denied writing to --target: config files under /etc typically need root/Administrator to write. Run under sudo on Linux or an elevated shell on Windows, or point --target at a staging location and have your deployment step move it into place with the right privileges.
  • YAML values coming through as strings when you expected numbers: unquoted YAML scalars are parsed with their natural type (8443 is an Integer), but if a vars file quotes a value (app_port: "8443") it renders as a string — harmless in most config formats but worth checking if your config parser is strict about types.
  • Want to validate the rendered output before writing it: pipe the render subcommand's output into whatever validator your target application ships (e.g., nginx -t -c) as a pre-check before running apply.

Extending This Script

  • Add a --check mode to diff designed for monitoring systems (Nagios/Icinga-style), printing a single-line status and using their expected exit code convention.
  • Support multiple templates and targets in one run via a manifest YAML file, so a single invocation can template an entire application's config directory at once.
  • Swap the simple index-based diff for the diff-lcs gem to get real unified-diff output when reviewing larger config files.
  • Add a rollback subcommand that finds the most recent .bak file for a target and restores it, so recovery doesn't require remembering the exact backup filename.
  • Combine this with the SSH fleet runner from this series: render locally, then push and apply across every host in your fleet in one pass, collecting drift/apply results the same way.

Leave a Reply

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

Click to access the login or register cheese