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
diffor 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

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 methodinside the template: your YAML key doesn't match the regex inBindingContext(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.diffshows 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 thediff-lcsgem for a proper line-alignment diff.- Permission denied writing to
--target: config files under/etctypically need root/Administrator to write. Run undersudoon Linux or an elevated shell on Windows, or point--targetat 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 (
8443is 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
rendersubcommand's output into whatever validator your target application ships (e.g.,nginx -t -c) as a pre-check before runningapply.
Extending This Script
- Add a
--checkmode todiffdesigned 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-lcsgem to get real unified-diff output when reviewing larger config files. - Add a
rollbacksubcommand that finds the most recent.bakfile 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.


