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 requiredfileutils, optparse, open3, time, and timeout from 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

Diagram of deploy.rb: a new release is built and health-checked before an atomic symlink swap goes live; deployments.log records only releases that actually went live, and rollback reads from that history instead of the raw releases directory

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

Terminal output showing a successful deploy, a failed deploy whose health check caught a broken release, and a rollback that correctly lands on the last working release

$ 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_check sets the new release directory as the command’s working directory (chdir:), not shared/ 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 thread warnings on a health-check timeout — this is Open3.capture3‘s reader threads reacting to Timeout.timeout forcibly 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 with Process.spawn and Process.wait under 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-file target is created blank with FileUtils.touch if it doesn’t already exist. Seed real content into shared/ before your first deploy if you need it present from day one.
  • rollback says “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 --keep window. You can’t roll back further than your retention policy allows.

Extending This Script

  • Replace FileUtils.cp_r with a git archive or 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 verify subcommand that re-runs the health check against whatever current already 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.

Leave a Reply

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