Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.
The Problem
The simplest possible deploy is git pull && restart, and it’s also the one that bites you: for the seconds between pulling new code and the process coming back up, requests fail. If the new code has a bug, “rollback” means remembering the previous commit, checking it out, and restarting again — under pressure, with something already broken. None of that requires a full CD platform to fix; it requires a deploy that never removes the old, working code until the new code has proven itself, and that can switch back in one atomic step.
This tutorial builds deploy.rb around the releases + current symlink pattern — the same technique underlying Capistrano and Mina — in plain Ruby with zero gem dependencies. Every deploy lands in its own timestamped directory under releases/; a symlink called current points at whichever one is live; and switching versions is a single atomic filesystem rename. A deploy only goes live if an optional health check passes first, and rolling back is one command that’s guaranteed to never reactivate a build that failed its own health check.
Prerequisites
- Ruby 3.0 or newer
- No gems required —
fileutils,optparse,open3,time, andtimeoutfrom the standard library - A POSIX filesystem (Linux or macOS). Symlinks and atomic rename are core to this pattern; Windows/NTFS has different symlink permission and rename semantics, so this script targets Linux/macOS deploy targets.
- Write access to the application directory you’re deploying into
The Script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# deploy.rb - zero-downtime deploys via the "releases + current symlink"
# pattern (the same technique Capistrano and Mina are built on), with a
# post-deploy health check gate and one-command rollback. Pure standard
# library -- no gems, no external services.
#
# Layout this script manages under --app-dir:
#
# myapp/
# releases/
# 20260718091500/
# 20260719140230/
# shared/ <- persists across every release (logs, .env, uploads)
# current -> releases/20260719140230
#
# Usage:
# ruby deploy.rb deploy --app-dir /srv/myapp --source ./build --health-check "ruby healthcheck.rb"
# ruby deploy.rb rollback --app-dir /srv/myapp
# ruby deploy.rb status --app-dir /srv/myapp
#
# Requires: Ruby 3.0+. Standard library only (fileutils, optparse, open3, time).
require 'fileutils'
require 'optparse'
require 'open3'
require 'time'
require 'timeout'
# Microsecond-resolution timestamp: guarantees release names are both
# unique AND correctly sortable in chronological order, even for two
# deploys triggered in the same wall-clock second (a random-suffix
# scheme would be unique but NOT reliably sortable, which would silently
# break "roll back to the previous release").
RELEASE_TIME_FORMAT = '%Y%m%d%H%M%S%6N'
RELEASE_DISPLAY_FORMAT = '%Y%m%d%H%M%S'
# ---------------------------------------------------------------------------
# Layout helpers
# ---------------------------------------------------------------------------
def releases_dir(app_dir) = File.join(app_dir, 'releases')
def shared_dir(app_dir) = File.join(app_dir, 'shared')
def current_link(app_dir) = File.join(app_dir, 'current')
def history_file(app_dir) = File.join(app_dir, 'deployments.log')
RELEASE_NAME_PATTERN = /\A\d{20}\z/.freeze
def list_releases(app_dir)
Dir.children(releases_dir(app_dir))
.select { |d| d.match?(RELEASE_NAME_PATTERN) }
.sort
rescue Errno::ENOENT
[]
end
def current_release(app_dir)
link = current_link(app_dir)
return nil unless File.symlink?(link)
File.basename(File.readlink(link))
end
# ---------------------------------------------------------------------------
# Deploy
# ---------------------------------------------------------------------------
# Returns the new release name on success, or raises DeployError on failure.
class DeployError < StandardError; end
def deploy(app_dir, source:, health_check:, keep:, linked_dirs: [], linked_files: [], health_timeout: 15)
FileUtils.mkdir_p([releases_dir(app_dir), shared_dir(app_dir)])
release_name = Time.now.utc.strftime(RELEASE_TIME_FORMAT)
release_path = File.join(releases_dir(app_dir), release_name)
raise DeployError, "release #{release_name} already exists (try again)" if File.exist?(release_path)
puts "==> Building release #{release_name}"
FileUtils.cp_r(source, release_path)
link_shared_paths(app_dir, release_path, linked_dirs: linked_dirs, linked_files: linked_files)
if health_check
puts "==> Running health check: #{health_check}"
ok, detail = run_health_check(release_path, health_check, health_timeout)
unless ok
puts "==> Health check FAILED: #{detail}"
puts "==> Leaving faulty release on disk for inspection: #{release_path}"
raise DeployError, "health check failed for #{release_name}: #{detail}"
end
puts '==> Health check passed'
end
swap_current(app_dir, release_name)
puts "==> current -> releases/#{release_name}"
pruned = prune_old_releases(app_dir, keep: keep)
puts "==> Pruned #{pruned.size} old release(s): #{pruned.join(', ')}" unless pruned.empty?
release_name
end
def link_shared_paths(app_dir, release_path, linked_dirs:, linked_files:)
linked_dirs.each do |rel|
target = File.join(shared_dir(app_dir), rel)
FileUtils.mkdir_p(target)
link_path = File.join(release_path, rel)
FileUtils.mkdir_p(File.dirname(link_path))
FileUtils.rm_rf(link_path) # release copy may have brought its own empty dir/file
FileUtils.ln_s(target, link_path)
end
linked_files.each do |rel|
target = File.join(shared_dir(app_dir), rel)
FileUtils.mkdir_p(File.dirname(target))
FileUtils.touch(target) unless File.exist?(target)
link_path = File.join(release_path, rel)
FileUtils.mkdir_p(File.dirname(link_path))
FileUtils.rm_f(link_path)
FileUtils.ln_s(target, link_path)
end
end
# Runs the health check command with the release directory as its cwd, under
# a hard wall-clock timeout. Returns [success_boolean, detail_string].
def run_health_check(release_path, command, timeout)
stdout_str = +''
stderr_str = +''
status = nil
Timeout.timeout(timeout) do
stdout_str, stderr_str, status = Open3.capture3(command, chdir: release_path)
end
if status&.success?
[true, stdout_str.strip]
else
[false, "exit #{status&.exitstatus}: #{stderr_str.strip.empty? ? stdout_str.strip : stderr_str.strip}"]
end
rescue Timeout::Error
[false, "timed out after #{timeout}s"]
end
# Atomically repoints `current`. Builds the new symlink under a temp name in
# the same directory, then renames it over the old one -- File.rename is an
# atomic operation on the same filesystem on Linux/macOS, so there's never a
# moment where `current` doesn't exist or points at a half-written release.
#
# Also appends to deployments.log, an append-only record of every release
# that actually went live. This is what rollback reads from -- NOT a
# directory listing of releases/. That distinction matters: a release
# whose health check failed still sits in releases/ (deliberately, for
# debugging), but it must never be treated as a valid rollback target,
# since it never served traffic in the first place.
def swap_current(app_dir, release_name)
link = current_link(app_dir)
tmp_link = "#{link}.tmp-#{Process.pid}"
FileUtils.rm_f(tmp_link)
FileUtils.ln_s(File.join('releases', release_name), tmp_link)
File.rename(tmp_link, link)
File.open(history_file(app_dir), 'a') { |f| f.puts("#{Time.now.utc.iso8601}\t#{release_name}") }
end
def deploy_history(app_dir)
return [] unless File.exist?(history_file(app_dir))
File.readlines(history_file(app_dir), chomp: true).filter_map do |line|
_ts, name = line.split("\t", 2)
name
end
end
def prune_old_releases(app_dir, keep:)
active = current_release(app_dir)
# Keep the current release plus the newest `keep` non-active releases;
# delete anything older than that.
candidates = list_releases(app_dir).reject { |r| r == active }.sort
to_remove = candidates.size > keep ? candidates[0...(candidates.size - keep)] : []
to_remove.each { |r| FileUtils.rm_rf(File.join(releases_dir(app_dir), r)) }
to_remove
end
# ---------------------------------------------------------------------------
# Rollback
# ---------------------------------------------------------------------------
def rollback(app_dir)
active = current_release(app_dir)
raise DeployError, 'no current release to roll back from' unless active
history = deploy_history(app_dir)
# Walk the activation history backwards from the most recent entry,
# skipping the current release itself, any repeated entries (re-running
# rollback twice shouldn't loop between the same two releases forever
# in a single call), and any release whose directory has since been
# pruned from disk.
target = history.reverse.drop_while { |r| r == active }.find { |r| release_exists?(app_dir, r) }
raise DeployError, 'no earlier (still on-disk) release available to roll back to' unless target
swap_current(app_dir, target)
target
end
def release_exists?(app_dir, release_name)
File.directory?(File.join(releases_dir(app_dir), release_name))
end
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
def status(app_dir)
releases = list_releases(app_dir)
active = current_release(app_dir)
releases.reverse.map { |r| { release: r, current: r == active } }
end
def print_status(app_dir)
rows = status(app_dir)
if rows.empty?
puts 'No releases yet.'
return
end
rows.each do |row|
marker = row[:current] ? '-> ' : ' '
ts = Time.strptime(row[:release][0, 14], RELEASE_DISPLAY_FORMAT).utc.iso8601
puts "#{marker}#{row[:release]} (#{ts})"
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
command = ARGV.shift
options = { keep: 5, health_timeout: 15, linked_dirs: [], linked_files: [] }
OptionParser.new do |opts|
opts.banner = 'Usage: deploy.rb [deploy|rollback|status] --app-dir DIR [options]'
opts.on('--app-dir DIR', 'Application root (contains releases/, shared/, current)') { |v| options[:app_dir] = v }
opts.on('--source DIR', 'Directory containing the new release to deploy') { |v| options[:source] = v }
opts.on('--health-check CMD', 'Command run inside the new release; nonzero exit blocks the swap') { |v| options[:health_check] = v }
opts.on('--health-timeout SECONDS', Integer, 'Health check timeout (default: 15)') { |v| options[:health_timeout] = v }
opts.on('--keep N', Integer, 'Old releases to retain besides current (default: 5)') { |v| options[:keep] = v }
opts.on('--link-dir REL', 'Shared directory to symlink into each release (repeatable)') { |v| options[:linked_dirs] << v }
opts.on('--link-file REL', 'Shared file to symlink into each release (repeatable)') { |v| options[:linked_files] << v }
opts.on('-h', '--help', 'Show this help') { puts opts; exit }
end.parse!
abort 'Error: --app-dir is required' unless options[:app_dir]
case command
when 'deploy'
abort 'Error: --source is required for deploy' unless options[:source]
begin
name = deploy(
options[:app_dir], source: options[:source], health_check: options[:health_check],
keep: options[:keep], linked_dirs: options[:linked_dirs], linked_files: options[:linked_files],
health_timeout: options[:health_timeout]
)
puts "Deploy succeeded: #{name}"
rescue DeployError => e
warn "Deploy failed: #{e.message}"
exit 1
end
when 'rollback'
begin
target = rollback(options[:app_dir])
puts "Rolled back to #{target}"
rescue DeployError => e
warn "Rollback failed: #{e.message}"
exit 1
end
when 'status'
print_status(options[:app_dir])
else
warn 'Usage: deploy.rb [deploy|rollback|status] --app-dir DIR (--help for details)'
exit 1
end
end
How It Works

The on-disk layout
Everything hangs off three things under --app-dir: a releases/ directory holding one subdirectory per deploy, a shared/ directory for anything that needs to persist across releases (logs, uploaded files, a .env), and a current symlink pointing at whichever release directory is live right now.
Building a release
deploy copies --source into a new directory named after the current UTC time — down to the microsecond. That precision isn’t decorative: an earlier version of this script used second-level timestamps, which meant two deploys triggered within the same second would collide. Microsecond resolution makes collisions practically impossible while keeping release names both unique and correctly sortable in chronological order — a property the rollback logic depends on.
Shared files and directories
link_shared_paths symlinks specific paths (given via repeatable --link-dir / --link-file flags) from shared/ into the new release. A .env file or a log/ directory written to a release-relative path actually lands in shared/, so it survives the next deploy instead of vanishing when that release gets pruned.
The health check gate
If --health-check is given, run_health_check runs it with the new release directory as its working directory, wrapped in Timeout.timeout and Open3.capture3 so both a hung command and a command that exits nonzero are caught. Only a passing health check reaches swap_current — a failing one leaves the broken release on disk (deliberately, for post-mortem debugging) and current completely untouched.
The atomic swap
swap_current builds the new symlink under a temporary name in the same directory, then calls File.rename to move it over the old one. rename(2) on the same filesystem is atomic on Linux and macOS — there is no instant in between where current is missing or points at a half-written release. This is the actual mechanism behind “zero-downtime”: from any concurrently running process’s point of view, current jumps directly from the old release to the new one.
Testing This Script — and a Real Bug It Caught
deploy.rb only does plain file operations, so it was tested against real temporary directories on disk rather than mocks — no test double needed. That honest testing surfaced a genuine bug in the first version of rollback, which is worth walking through because it’s the kind of mistake that’s easy to ship in infrastructure tooling and only shows up during an actual incident.
The original rollback picked its target by listing every directory under releases/ and choosing the one immediately before current in sorted order. That seems reasonable until you remember that a release whose health check failed is deliberately left on disk for debugging — it’s still a directory under releases/, it just never went live. A regression test that deployed v1 successfully, attempted a v2 deploy with a failing health check, then successfully deployed v3, exposed the problem immediately: calling rollback from v3 selected the broken v2 build — the one that had never served a single request — instead of v1, the last release that actually worked. Rolling back would have made the outage worse, not better.
The fix was to stop trusting the directory listing for this decision entirely. swap_current now appends every release name it actually activates to an append-only deployments.log, and rollback walks that log backward from the most recent entry — so a release that never went live is structurally impossible to select, because it was never written to the log in the first place. rollback also skips any historical entry whose directory has since been deleted by prune_old_releases, falling further back in history until it finds one still on disk. Both scenarios are covered by dedicated regression tests. The full suite:
$ ruby test_deploy.rb
Run options: --seed 60078
# Running:
.............
Finished in 5.394388s, 2.4099 runs/s, 7.0444 assertions/s.
13 runs, 38 assertions, 0 failures, 0 errors, 0 skips
That includes test_rollback_skips_a_release_that_failed_its_health_check and test_rollback_falls_back_past_a_pruned_release, plus coverage of the health-check pass/fail/timeout paths, shared-file persistence across deploys, and pruning behavior.
Example Output

$ ruby deploy.rb deploy --app-dir /srv/myapp --source ./build --health-check "ruby healthcheck.rb"
==> Building release 20260719184258876014
==> Running health check: ruby healthcheck.rb
==> Health check passed
==> current -> releases/20260719184258876014
Deploy succeeded: 20260719184258876014
$ ruby deploy.rb deploy --app-dir /srv/myapp --source ./build-v2-bad --health-check "ruby healthcheck.rb"
==> Building release 20260719184312349021
==> Running health check: ruby healthcheck.rb
==> Health check FAILED: exit 1: connection refused on :4567
==> Leaving faulty release on disk for inspection: releases/20260719184312349021
Deploy failed: health check failed for 20260719184312349021
$ ruby deploy.rb rollback --app-dir /srv/myapp
Rolled back to 20260719184258876014
Troubleshooting
- Symlink creation fails or is silently ignored — on network filesystems (some NFS configurations) or on Windows, symlink support and permissions differ from local Linux/macOS disks. Confirm your deploy target is a POSIX filesystem with symlink support before relying on this pattern.
- Health check can’t find files it expects — remember
run_health_checksets the new release directory as the command’s working directory (chdir:), notshared/or the app root. Reference shared paths with the same relative links you configured via--link-file/--link-dir. - Noisy
IOError: stream closed in another threadwarnings on a health-check timeout — this isOpen3.capture3‘s reader threads reacting toTimeout.timeoutforcibly interrupting the block; it’s cosmetic and doesn’t affect correctness (the deploy is still correctly reported as failed), but if it bothers you, swap to manually managing the subprocess withProcess.spawnandProcess.waitunder your own timeout instead. - “release already exists” error — this should only happen if your system clock is frozen or mocked, since release names are unique to the microsecond. Investigate your clock source before retrying.
- Shared files are empty after the first deploy — a first-ever
--link-filetarget is created blank withFileUtils.touchif it doesn’t already exist. Seed real content intoshared/before your first deploy if you need it present from day one. rollbacksays “no earlier (still on-disk) release” — either this is the first release that’s ever gone live, or every earlier one has been pruned past your--keepwindow. You can’t roll back further than your retention policy allows.
Extending This Script
- Replace
FileUtils.cp_rwith agit archiveor shallow clone straight into the release directory, so deploys pull from a specific commit SHA instead of a local build folder. - Add pre/post-deploy hooks — asset compilation, cache warming, database migrations — as additional commands run inside the release directory before or after the health check.
- Record richer metadata in
deployments.log(git SHA, deployer identity, deploy duration) for a real audit trail, not just a rollback pointer. - Add a
verifysubcommand that re-runs the health check against whatevercurrentalready points at, for periodic self-checks independent of a deploy. - Notify a webhook on deploy success, deploy failure, and rollback, using the same JSON POST pattern used elsewhere in this series.
- If you’re deploying the same release to many machines, pair this script with a fleet-wide command runner so one invocation triggers the same atomic swap everywhere.


