Some deploy targets never left FTP — shared hosts, print vendors, legacy EDI partners. Here’s a zero-gem Ruby sync tool that only ships what changed, and cleans up what shouldn’t be there anymore.
FTP is not glamorous, but it’s still how a surprising number of shared hosts, print vendors, older EDI trading partners, and some managed CDNs accept file drops. The naive fix — re-upload the whole directory every run — works right up until the directory has thousands of files and the nightly job starts burning an hour re-sending bytes that never changed.
ftp_dir_sync.rb gives that job real sync semantics using only Ruby’s bundled net/ftp — no gems to install on a locked-down deploy box. It skips unchanged files, creates missing remote directories on the fly, retries transient network errors, and can optionally delete remote files that no longer have a local counterpart.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# ftp_dir_sync.rb -- One-way directory sync from a local path up to a remote
# FTP server, using only Ruby's bundled net/ftp (no third-party gems).
#
# Solves a genuinely common ops task: shipping build artifacts, static site
# exports, or nightly report files up to a legacy FTP drop (many shared
# hosts, print vendors, and older EDI partners still only accept FTP).
# Skips files that are already present and unchanged (by size, then a byte
# comparison for same-size files) so re-runs are cheap, creates missing
# remote directories, retries transient failures, and supports a dry run.
#
# Usage:
# ruby ftp_dir_sync.rb --host ftp.example.com --user deploy --local ./dist --remote /www/site
# FTP_PASSWORD=secret ruby ftp_dir_sync.rb --host ftp.example.com --user deploy \
# --local ./dist --remote /www/site --delete-orphans --dry-run
#
# Exit codes: 0 = sync completed (possibly with 0 files to do)
# 1 = fatal error (couldn't connect/login)
# 2 = one or more files failed to transfer after retries
require 'net/ftp'
require 'optparse'
require 'find'
require 'digest'
options = {
host: nil, port: 21, user: 'anonymous', password: ENV['FTP_PASSWORD'],
local: nil, remote: '/', passive: true, retries: 3, dry_run: false,
delete_orphans: false
}
OptionParser.new do |o|
o.banner = 'Usage: ftp_dir_sync.rb --host HOST --local DIR --remote DIR [options]'
o.on('--host HOST', 'FTP server hostname') { |v| options[:host] = v }
o.on('--port PORT', Integer, 'FTP port (default 21)') { |v| options[:port] = v }
o.on('--user USER', 'FTP username (default anonymous)') { |v| options[:user] = v }
o.on('--password PASS', 'FTP password (prefer FTP_PASSWORD env var instead)') { |v| options[:password] = v }
o.on('--local DIR', 'Local directory to push') { |v| options[:local] = v }
o.on('--remote DIR', 'Remote directory to push into') { |v| options[:remote] = v }
o.on('--no-passive', 'Use active mode instead of passive') { options[:passive] = false }
o.on('--retries N', Integer, 'Retry attempts per file (default 3)') { |v| options[:retries] = v }
o.on('--delete-orphans', 'Delete remote files with no local counterpart') { options[:delete_orphans] = true }
o.on('--dry-run', 'Show what would happen without transferring anything') { options[:dry_run] = true }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end.parse!
if options[:host].nil? || options[:local].nil?
warn 'Error: --host and --local are required (see --help)'
exit 1
end
unless Dir.exist?(options[:local])
warn "Error: local directory #{options[:local]} does not exist"
exit 1
end
# Normalize away a trailing slash so path joins below never produce "//".
# (root "/" becomes "", so later "#{remote}/#{rel}" joins still come out as
# the correct single-leading-slash absolute path.)
options[:remote] = options[:remote].sub(%r{/+\z}, '')
# ---------------------------------------------------------------------------
# Walk the local tree and build a map of relative_path => {size:, abs_path:}
# ---------------------------------------------------------------------------
def local_manifest(root)
manifest = {}
Find.find(root) do |path|
next if File.directory?(path)
rel = path.sub(%r{\A#{Regexp.escape(root)}/?}, '')
manifest[rel] = { abs_path: path, size: File.size(path) }
end
manifest
end
# Ensure every directory component of a remote relative path exists,
# creating them one level at a time (FTP has no `mkdir -p`).
def ensure_remote_dir(ftp, remote_root, rel_dir)
return if rel_dir.empty? || rel_dir == '.'
parts = rel_dir.split('/')
current = remote_root
parts.each do |part|
current = "#{current}/#{part}"
begin
ftp.mkdir(current)
rescue Net::FTPPermError => e
# 550 "already exists" is expected and fine; anything else re-raises.
raise unless e.message.include?('550')
end
end
end
# Compare local and remote by size first (cheap), and only fall back to a
# full byte-for-byte re-download-and-compare for same-size files, since FTP
# has no standard remote-checksum command.
def unchanged?(ftp, remote_path, local_info)
remote_size = ftp.size(remote_path)
return false unless remote_size == local_info[:size]
tmp = "#{local_info[:abs_path]}.remote_check.tmp"
begin
ftp.getbinaryfile(remote_path, tmp)
Digest::SHA256.file(tmp).hexdigest == Digest::SHA256.file(local_info[:abs_path]).hexdigest
ensure
File.delete(tmp) if File.exist?(tmp)
end
rescue Net::FTPPermError
false # remote file doesn't exist yet
end
# Recursively list every FILE (never directories) under a remote root, by
# walking with NLST + a directory-or-not probe. NLST alone is not recursive,
# and naively deleting whatever NLST returns at the top level can hand you a
# directory and blow up with "550 Is a directory" -- so we only ever collect
# and delete leaf files here, and existing subdirectories are simply left in
# place (deleting an emptied-out directory is a rmdir, not a delete, and
# isn't attempted by this script).
def remote_files_recursive(ftp, dir)
files = []
entries = begin
ftp.nlst(dir)
rescue Net::FTPPermError
[]
end
entries.each do |entry|
name = File.basename(entry)
next if %w[. ..].include?(name)
full_path = entry.start_with?('/') ? entry : "#{dir}/#{name}"
if directory?(ftp, full_path)
files.concat(remote_files_recursive(ftp, full_path))
else
files << full_path
end
end
files
end
def directory?(ftp, path)
ftp.size(path)
false
rescue Net::FTPPermError
# Not sizeable -- almost always because it's a directory. A genuinely
# missing path would already have been filtered out by the NLST above.
true
end
def with_retries(retries)
attempt = 0
begin
attempt += 1
yield
rescue Net::FTPTempError, Net::FTPConnectionError, Errno::ECONNRESET, EOFError => e
if attempt <= retries
warn " retry #{attempt}/#{retries} after: #{e.message}"
sleep(0.5 * attempt)
retry
end
raise
end
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
manifest = local_manifest(options[:local])
puts "Found #{manifest.size} local file(s) under #{options[:local]}"
uploaded = skipped = failed = deleted = 0
begin
ftp = Net::FTP.new
ftp.passive = options[:passive]
ftp.connect(options[:host], options[:port])
ftp.login(options[:user], options[:password])
puts "Connected to #{options[:host]}:#{options[:port]} as #{options[:user]} (passive=#{options[:passive]})"
rescue StandardError => e
warn "FATAL: could not connect/login: #{e.message}"
exit 1
end
# Make sure the remote root itself exists before anything tries to upload
# into it -- ensure_remote_dir below only creates directories *under* a
# known-to-exist root, so the root is a special case handled once up front.
unless options[:dry_run] || options[:remote].empty?
begin
ensure_remote_dir(ftp, '', options[:remote].sub(%r{\A/}, ''))
rescue StandardError => e
warn "FATAL: could not create remote root #{options[:remote]}: #{e.message}"
exit 1
end
end
remote_seen = []
manifest.each do |rel, info|
remote_path = "#{options[:remote]}/#{rel}"
remote_seen << remote_path
rel_dir = File.dirname(rel)
if unchanged?(ftp, remote_path, info)
skipped += 1
next
end
if options[:dry_run]
puts "[dry-run] would upload #{rel} -> #{remote_path}"
uploaded += 1
next
end
begin
with_retries(options[:retries]) do
ensure_remote_dir(ftp, options[:remote], rel_dir)
ftp.putbinaryfile(info[:abs_path], remote_path)
end
puts "uploaded #{rel} (#{info[:size]} bytes)"
uploaded += 1
rescue StandardError => e
warn "FAILED #{rel}: #{e.message}"
failed += 1
end
end
if options[:delete_orphans]
begin
remote_files = remote_files_recursive(ftp, options[:remote])
orphans = remote_files - remote_seen
orphans.each do |orphan|
if options[:dry_run]
puts "[dry-run] would delete orphan #{orphan}"
else
ftp.delete(orphan)
puts "deleted orphan #{orphan}"
end
deleted += 1
end
rescue Net::FTPPermError => e
warn "Could not list remote directory for orphan cleanup: #{e.message}"
end
end
ftp.close
puts "Done. uploaded=#{uploaded} skipped=#{skipped} failed=#{failed} deleted=#{deleted}"
exit(failed.positive? ? 2 : 0)
Change detection without a remote checksum command. FTP has no standard “hash this file” verb, so the script compares sizes first via ftp.size — cheap — and only for a same-size file does it download to a temp path and SHA-256-compare against the local copy. The expensive path stays rare.
Directories aren’t files. The first version of --delete-orphans called ftp.nlst at the top level and tried to delete whatever came back — which included subdirectories, and blew up with 550 Is a directory the first time it was tested against a real server. The fix, remote_files_recursive, walks the tree explicitly and probes each entry with ftp.size (a directory raises Net::FTPPermError) before ever calling ftp.delete.
Retries are narrow on purpose. with_retries only catches the exceptions that mean “try again” — Net::FTPTempError, connection resets, EOF — and lets everything else (like a permissions error) fail immediately instead of retrying something that will never succeed.
The remote root itself needs creating too. A second bug caught during testing: ensure_remote_dir only created directories under the remote root, so a brand-new --remote /site target failed every upload with “no such file or directory” until the script was fixed to create the root itself before the per-file loop starts.
$ ruby ftp_dir_sync.rb --host ftp.example.com --user deploy --local ./dist --remote /www/site Found 2 local file(s) under ./dist Connected to ftp.example.com:21 as deploy (passive=true) uploaded assets/style.css (16 bytes) uploaded index.html (19 bytes) Done. uploaded=2 skipped=0 failed=0 deleted=0 $ ruby ftp_dir_sync.rb --host ftp.example.com --user deploy --local ./dist --remote /www/site # re-run, nothing changed Found 2 local file(s) under ./dist Connected to ftp.example.com:21 as deploy (passive=true) Done. uploaded=0 skipped=2 failed=0 deleted=0 $ ruby ftp_dir_sync.rb --host ftp.example.com --user deploy --local ./dist --remote /www/site --delete-orphans Found 2 local file(s) under ./dist Connected to ftp.example.com:21 as deploy (passive=true) uploaded extra.txt (4 bytes) uploaded readme.txt (9 bytes) deleted orphan /www/site/subdir/asset.bin Done. uploaded=2 skipped=0 failed=0 deleted=1
Full script + README on GitHub: ruby-devops-toolkit/ftp-dir-sync
Prerequisites
- Ruby ≥ 3.0.
net/ftpshipped in the standard library through Ruby 3.0 and today ships as a default gem bundled with every stock Ruby install (3.1+) — either way, nogem installis required. - Network access to the FTP server, including the passive-mode data port range if a firewall restricts it.
- An FTP account with write (and, for
--delete-orphans, delete) permission on the target directory.
Full script for reference
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# ftp_dir_sync.rb -- One-way directory sync from a local path up to a remote
# FTP server, using only Ruby's bundled net/ftp (no third-party gems).
#
# Solves a genuinely common ops task: shipping build artifacts, static site
# exports, or nightly report files up to a legacy FTP drop (many shared
# hosts, print vendors, and older EDI partners still only accept FTP).
# Skips files that are already present and unchanged (by size, then a byte
# comparison for same-size files) so re-runs are cheap, creates missing
# remote directories, retries transient failures, and supports a dry run.
#
# Usage:
# ruby ftp_dir_sync.rb --host ftp.example.com --user deploy --local ./dist --remote /www/site
# FTP_PASSWORD=secret ruby ftp_dir_sync.rb --host ftp.example.com --user deploy \
# --local ./dist --remote /www/site --delete-orphans --dry-run
#
# Exit codes: 0 = sync completed (possibly with 0 files to do)
# 1 = fatal error (couldn't connect/login)
# 2 = one or more files failed to transfer after retries
require 'net/ftp'
require 'optparse'
require 'find'
require 'digest'
options = {
host: nil, port: 21, user: 'anonymous', password: ENV['FTP_PASSWORD'],
local: nil, remote: '/', passive: true, retries: 3, dry_run: false,
delete_orphans: false
}
OptionParser.new do |o|
o.banner = 'Usage: ftp_dir_sync.rb --host HOST --local DIR --remote DIR [options]'
o.on('--host HOST', 'FTP server hostname') { |v| options[:host] = v }
o.on('--port PORT', Integer, 'FTP port (default 21)') { |v| options[:port] = v }
o.on('--user USER', 'FTP username (default anonymous)') { |v| options[:user] = v }
o.on('--password PASS', 'FTP password (prefer FTP_PASSWORD env var instead)') { |v| options[:password] = v }
o.on('--local DIR', 'Local directory to push') { |v| options[:local] = v }
o.on('--remote DIR', 'Remote directory to push into') { |v| options[:remote] = v }
o.on('--no-passive', 'Use active mode instead of passive') { options[:passive] = false }
o.on('--retries N', Integer, 'Retry attempts per file (default 3)') { |v| options[:retries] = v }
o.on('--delete-orphans', 'Delete remote files with no local counterpart') { options[:delete_orphans] = true }
o.on('--dry-run', 'Show what would happen without transferring anything') { options[:dry_run] = true }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end.parse!
if options[:host].nil? || options[:local].nil?
warn 'Error: --host and --local are required (see --help)'
exit 1
end
unless Dir.exist?(options[:local])
warn "Error: local directory #{options[:local]} does not exist"
exit 1
end
# Normalize away a trailing slash so path joins below never produce "//".
# (root "/" becomes "", so later "#{remote}/#{rel}" joins still come out as
# the correct single-leading-slash absolute path.)
options[:remote] = options[:remote].sub(%r{/+\z}, '')
# ---------------------------------------------------------------------------
# Walk the local tree and build a map of relative_path => {size:, abs_path:}
# ---------------------------------------------------------------------------
def local_manifest(root)
manifest = {}
Find.find(root) do |path|
next if File.directory?(path)
rel = path.sub(%r{\A#{Regexp.escape(root)}/?}, '')
manifest[rel] = { abs_path: path, size: File.size(path) }
end
manifest
end
# Ensure every directory component of a remote relative path exists,
# creating them one level at a time (FTP has no `mkdir -p`).
def ensure_remote_dir(ftp, remote_root, rel_dir)
return if rel_dir.empty? || rel_dir == '.'
parts = rel_dir.split('/')
current = remote_root
parts.each do |part|
current = "#{current}/#{part}"
begin
ftp.mkdir(current)
rescue Net::FTPPermError => e
# 550 "already exists" is expected and fine; anything else re-raises.
raise unless e.message.include?('550')
end
end
end
# Compare local and remote by size first (cheap), and only fall back to a
# full byte-for-byte re-download-and-compare for same-size files, since FTP
# has no standard remote-checksum command.
def unchanged?(ftp, remote_path, local_info)
remote_size = ftp.size(remote_path)
return false unless remote_size == local_info[:size]
tmp = "#{local_info[:abs_path]}.remote_check.tmp"
begin
ftp.getbinaryfile(remote_path, tmp)
Digest::SHA256.file(tmp).hexdigest == Digest::SHA256.file(local_info[:abs_path]).hexdigest
ensure
File.delete(tmp) if File.exist?(tmp)
end
rescue Net::FTPPermError
false # remote file doesn't exist yet
end
# Recursively list every FILE (never directories) under a remote root, by
# walking with NLST + a directory-or-not probe. NLST alone is not recursive,
# and naively deleting whatever NLST returns at the top level can hand you a
# directory and blow up with "550 Is a directory" -- so we only ever collect
# and delete leaf files here, and existing subdirectories are simply left in
# place (deleting an emptied-out directory is a rmdir, not a delete, and
# isn't attempted by this script).
def remote_files_recursive(ftp, dir)
files = []
entries = begin
ftp.nlst(dir)
rescue Net::FTPPermError
[]
end
entries.each do |entry|
name = File.basename(entry)
next if %w[. ..].include?(name)
full_path = entry.start_with?('/') ? entry : "#{dir}/#{name}"
if directory?(ftp, full_path)
files.concat(remote_files_recursive(ftp, full_path))
else
files << full_path
end
end
files
end
def directory?(ftp, path)
ftp.size(path)
false
rescue Net::FTPPermError
# Not sizeable -- almost always because it's a directory. A genuinely
# missing path would already have been filtered out by the NLST above.
true
end
def with_retries(retries)
attempt = 0
begin
attempt += 1
yield
rescue Net::FTPTempError, Net::FTPConnectionError, Errno::ECONNRESET, EOFError => e
if attempt <= retries
warn " retry #{attempt}/#{retries} after: #{e.message}"
sleep(0.5 * attempt)
retry
end
raise
end
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
manifest = local_manifest(options[:local])
puts "Found #{manifest.size} local file(s) under #{options[:local]}"
uploaded = skipped = failed = deleted = 0
begin
ftp = Net::FTP.new
ftp.passive = options[:passive]
ftp.connect(options[:host], options[:port])
ftp.login(options[:user], options[:password])
puts "Connected to #{options[:host]}:#{options[:port]} as #{options[:user]} (passive=#{options[:passive]})"
rescue StandardError => e
warn "FATAL: could not connect/login: #{e.message}"
exit 1
end
# Make sure the remote root itself exists before anything tries to upload
# into it -- ensure_remote_dir below only creates directories *under* a
# known-to-exist root, so the root is a special case handled once up front.
unless options[:dry_run] || options[:remote].empty?
begin
ensure_remote_dir(ftp, '', options[:remote].sub(%r{\A/}, ''))
rescue StandardError => e
warn "FATAL: could not create remote root #{options[:remote]}: #{e.message}"
exit 1
end
end
remote_seen = []
manifest.each do |rel, info|
remote_path = "#{options[:remote]}/#{rel}"
remote_seen << remote_path
rel_dir = File.dirname(rel)
if unchanged?(ftp, remote_path, info)
skipped += 1
next
end
if options[:dry_run]
puts "[dry-run] would upload #{rel} -> #{remote_path}"
uploaded += 1
next
end
begin
with_retries(options[:retries]) do
ensure_remote_dir(ftp, options[:remote], rel_dir)
ftp.putbinaryfile(info[:abs_path], remote_path)
end
puts "uploaded #{rel} (#{info[:size]} bytes)"
uploaded += 1
rescue StandardError => e
warn "FAILED #{rel}: #{e.message}"
failed += 1
end
end
if options[:delete_orphans]
begin
remote_files = remote_files_recursive(ftp, options[:remote])
orphans = remote_files - remote_seen
orphans.each do |orphan|
if options[:dry_run]
puts "[dry-run] would delete orphan #{orphan}"
else
ftp.delete(orphan)
puts "deleted orphan #{orphan}"
end
deleted += 1
end
rescue Net::FTPPermError => e
warn "Could not list remote directory for orphan cleanup: #{e.message}"
end
end
ftp.close
puts "Done. uploaded=#{uploaded} skipped=#{skipped} failed=#{failed} deleted=#{deleted}"
exit(failed.positive? ? 2 : 0)
Step-by-step: what happens on a run
- 1. Build a local manifest of every file under
--localwithFind.find. - 2. Connect and log in, then ensure the
--remoteroot directory exists. - 3. Per file: compare size, then (only if sizes match) SHA-256, then skip or upload with retries.
- 4. If
--delete-orphans: recursively list every remote file, subtract what was just uploaded/skipped, and delete what’s left over. - 5. Report uploaded/skipped/failed/deleted counts and exit 0/1/2 accordingly.
Troubleshooting
Extending it
- Write a local
.ftpsync-state.jsonmanifest after each run so unchanged-file checks never need a same-size download+compare at all. - Add TLS via
Net::FTP.new(host, ssl: true)(FTPS) for servers that support it. - Parallelize uploads with a small thread pool for directories with many small files.