Exporting files to Dropbox in Ruby using the Dropbox SDK
Use the community dropbox_api gem to export local files
from Ruby. The command below refreshes its access token, uploads to an explicit Dropbox path, and
downloads the result to check every byte before reporting completion. With an app-folder app,
/report.csv means a file inside your app’s folder; do not prepend /Apps/YourAppFolder.
Prerequisites
This walkthrough targets existing Ruby 3.4 applications on Linux with Bash and
Bundler installed.
The reproduction versions are Ruby 3.4.10, Bundler 4.0.23, and dropbox_api 0.1.21. Ruby 3.4 is
maintained; use its current patched release for
your application. Ruby 4.0 is the current stable major, but this walkthrough’s runtime scope is 3.4.
The gem’s latest published release is still
0.1.21, from March 2022, so compatibility here is a tested
combination rather than an upstream promise of current Ruby support.
You need a Dropbox account and a private server-side environment that can keep an app secret and refresh token. Use stable regular files with UTF-8 filenames. The example accepts empty files and binary data, and caps each file at 16 MiB because verification buffers the complete download. Remote paths must contain ASCII characters; use file mode to give a UTF-8 local filename an ASCII remote name. Directory mode consequently requires ASCII basenames. Run exports sequentially and do not modify their inputs during a run. Team-space namespace selection and concurrent writers are outside this example.
Setting up a Dropbox app and obtaining access tokens
Create an app in the Dropbox App Console. Choose
App folder for an exporter that only needs its own files.
An existing Full Dropbox app can use the same code, but paths
then start at the user’s default Dropbox root. App-folder access limits the available content;
OAuth scopes separately limit operations.
Enable files.content.write, files.content.read, and files.metadata.read before authorizing:
the command uploads, downloads, and checks for an existing destination.
For an app-folder app, API path /Exports/report.csv appears beneath the app’s folder in Dropbox.
For a full-access app, it appears beneath /Exports in the default user root. Create any parent
folders in Dropbox first and use their exact capitalization. These are
API paths, not website URLs or local
filesystem paths. The ASCII restriction avoids non-ASCII argument headers in this gem
version; Dropbox’s header encoding rules
require escaping such characters.
Supply DROPBOX_APP_KEY and DROPBOX_APP_SECRET as environment variables through your secret
manager or private shell environment. Do not put secrets in source control or command arguments.
We will request offline authorization once, then use its refresh token for later unattended runs.
Dropbox’s code-flow guide permits omitting a
redirect URI for this local, copy-and-paste authorization flow.
Installing the Dropbox SDK for Ruby
In a Bash session without BUNDLE_* overrides, paste this block from a writable parent directory.
It creates a new dropbox-export project; an existing directory is an error and is left alone.
The subshell keeps your current directory and shell options intact if installation fails.
(
set -eu
command -v ruby >/dev/null
command -v bundle >/dev/null
mkdir dropbox-export
cd dropbox-export
cat > Gemfile <<'RUBY'
source 'https://rubygems.org'
gem 'dropbox_api', '0.1.21'
RUBY
bundle config set --local path vendor/bundle
bundle install
)
Open the resulting directory in your editor and use it as the working directory for the remaining
commands. Keep the generated Gemfile.lock with your application so later installations use the
same resolved dependencies. If installation fails after creating the project, inspect its files
and rerun bundle install from that directory; do not delete an existing project to restart setup.
Authenticating with Dropbox in Ruby
Save this as authorize_dropbox.rb. Its url command prints an authorization URL. Its exchange
command reads DROPBOX_AUTH_CODE from the environment and saves only the refresh token in a new
owner-readable file named refresh-token. It refuses to replace an existing token file.
require 'dropbox_api'
def authorize
auth = DropboxApi::Authenticator.new(
ENV.fetch('DROPBOX_APP_KEY'), ENV.fetch('DROPBOX_APP_SECRET')
)
case ARGV
when ['url']
puts auth.auth_code.authorize_url(token_access_type: 'offline')
when ['exchange']
raise 'refresh-token already exists' if File.exist?('refresh-token')
token = auth.auth_code.get_token(ENV.fetch('DROPBOX_AUTH_CODE'))
raise 'No refresh token returned' if token.refresh_token.to_s.empty?
File.open('refresh-token', File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
file.write(token.refresh_token)
end
puts 'Authorization saved to refresh-token'
else
raise ArgumentError, 'Use url or exchange'
end
end
begin
authorize
rescue StandardError => error
warn "Authorization failed (#{error.class})"
exit 1
end
Run:
bundle exec ruby authorize_dropbox.rb url
Open the printed URL yourself, authorize the app, and supply the displayed code through
DROPBOX_AUTH_CODE in the same private environment. Then run:
bundle exec ruby authorize_dropbox.rb exchange
Keep refresh-token out of source control and restrict its backups too. You can move it to private
storage and set DROPBOX_REFRESH_TOKEN_FILE to its absolute path. The exporter constructs a new
access token from this refresh token on every invocation; it does not depend on a console-generated
access token still being valid. Revoked authorization requires repeating the authorization flow.
Uploading files to Dropbox using Ruby
Save this complete program as export_dropbox.rb. Files below 4 MiB use a single upload request;
at 4 MiB and above, the gem uses upload sessions with its default 4 MiB chunks. This deliberately
small threshold keeps the session path usable within the example’s 16 MiB cap. Dropbox’s
upload endpoint
allows larger single requests, but this example does not need to approach that limit.
require 'digest'
require 'dropbox_api'
require 'json'
require 'stringio'
MAX_SINGLE_REQUEST_UPLOAD = 4 * 1024 * 1024
MAX_FILE_BYTES = 16 * 1024 * 1024
def utf8_path(value)
path = value.dup.force_encoding(Encoding::UTF_8)
raise ArgumentError, 'Paths must be valid UTF-8' unless path.valid_encoding?
path
end
def remote_path(value)
path = utf8_path(value)
raise ArgumentError, 'Dropbox paths must contain ASCII only' unless path.ascii_only?
parts = path.split('/', -1)
unless path.start_with?('/') && parts.drop(1).all? { |part| !['', '.', '..'].include?(part) }
raise ArgumentError, 'Use an absolute Dropbox file or folder path without empty, . or .. segments'
end
path
end
def initialize_dropbox_client
auth = DropboxApi::Authenticator.new(
ENV.fetch('DROPBOX_APP_KEY'), ENV.fetch('DROPBOX_APP_SECRET')
)
refresh_token = File.read(ENV.fetch('DROPBOX_REFRESH_TOKEN_FILE', 'refresh-token')).strip
raise ArgumentError, 'Refresh token is empty' if refresh_token.empty?
token = OAuth2::AccessToken.new(auth, '', refresh_token: refresh_token, expires_at: 0)
DropboxApi::Client.new(access_token: token)
end
def with_rate_limit(max_retries = 3)
retries = 0
begin
yield
rescue DropboxApi::Errors::TooManyRequestsError => error
raise if retries >= max_retries
retries += 1
wait = error.retry_after.to_i.positive? ? error.retry_after.to_i : 2**retries
sleep(wait)
retry
end
end
def upload_file(client, file_path, dropbox_path)
local = utf8_path(file_path)
destination = remote_path(dropbox_path)
raise ArgumentError, 'Input must be a regular file' unless File.file?(local)
raise ArgumentError, 'File exceeds 16 MiB' if File.size(local) > MAX_FILE_BYTES
bytes = File.binread(local)
raise ArgumentError, 'File exceeds 16 MiB' if bytes.bytesize > MAX_FILE_BYTES
existing = begin
with_rate_limit { client.get_metadata(destination) }
rescue DropboxApi::Errors::NotFoundError
nil
end
raise 'Destination already exists' unless existing.nil?
uploaded = if bytes.bytesize >= MAX_SINGLE_REQUEST_UPLOAD
with_rate_limit { client.upload_by_chunks(destination, StringIO.new(bytes), mode: :add, autorename: false) }
else
with_rate_limit { client.upload(destination, bytes, mode: :add, autorename: false) }
end
downloaded = nil
stored = with_rate_limit do
client.download("rev:#{uploaded.rev}") do |body|
downloaded = body.nil? ? ''.b : body.b
end
end
unless uploaded.path_display == destination && stored.path_display == destination &&
uploaded.size == bytes.bytesize && stored.size == bytes.bytesize && downloaded == bytes
raise 'Stored file did not match the requested path and bytes'
end
puts JSON.generate(path: destination, bytes: bytes.bytesize, sha256: Digest::SHA256.hexdigest(bytes))
end
class DropboxExporter
def initialize(client)
@client = client
end
def process_directory(local_dir, dropbox_dir)
local = utf8_path(local_dir)
remote = remote_path(dropbox_dir)
raise ArgumentError, 'Input must be a directory' unless File.directory?(local)
Dir.children(local).sort.each do |name|
path = File.join(local, name)
next unless File.file?(path)
upload_file(@client, path, "#{remote}/#{utf8_path(name)}")
end
end
end
if $PROGRAM_NAME == __FILE__
begin
raise ArgumentError, 'Use file|directory LOCAL DROPBOX_PATH' unless ARGV.length == 3
mode, local, remote = ARGV
raise ArgumentError, 'Use file or directory' unless ['file', 'directory'].include?(mode)
client = initialize_dropbox_client
if mode == 'file'
upload_file(client, local, remote)
else
DropboxExporter.new(client).process_directory(local, remote)
end
puts 'Export complete'
rescue StandardError => error
warn "Export failed (#{error.class}); inspect the destination before retrying"
exit 1
end
end
From this project directory, export an existing local file to the app’s root:
bundle exec ruby export_dropbox.rb file ./report.csv /report.csv
A successful command prints a JSON record with the API path, byte count, and ordinary SHA-256 of the local bytes, followed by Export complete. The download uses the uploaded revision, so the comparison checks that version’s complete contents. The gem buffers that download; chunking the upload does not make this a constant-memory exporter.
Handling errors and exceptions
The command exits nonzero for missing files or configuration, refused authorization, existing
destinations, failed session completion, and readback mismatches. Its diagnostic names the exception
class without printing provider response bodies or credentials. A NotFoundError during readback
means the requested revision could not be read; check the destination and app permissions. An
OAuth2::Error during refresh means authorization did not succeed.
Only TooManyRequestsError is retried, up to three times per operation. The delay uses a positive
retry_after value, otherwise 2, 4, and 8 seconds. Other failures propagate. If an upload succeeds
but its response or verification is lost, the remote file may still exist: inspect it before retrying.
The script does not delete remote data on failure. A started session may also remain unfinished.
The existing-destination check makes sequential reruns fail even for identical bytes. It is not a
lock: keep other writers out of the destination folder during an export. Uploads request mode: :add
and autorename: false; the script never requests overwrite or accepts a renamed result as success.
Automating file exports in your Ruby application
Create /Exports inside your app folder in Dropbox, then export the regular files directly inside
an existing local exports directory:
bundle exec ruby export_dropbox.rb directory ./exports /Exports
The directory mode includes hidden regular files, skips subdirectories, and stops on the first failure. Earlier files remain exported and verified; this is not an all-or-nothing directory transaction. An empty directory has no files to export. Use a new destination folder for each scheduled batch, or track completed paths before selecting inputs for another run.
In a Rails job, require export_dropbox.rb, construct the client inside the job, and call
upload_file or DropboxExporter.new(client).process_directory. These methods raise on failure,
so let the job record that failure. Keep its credentials available through the same private
configuration and inspect any uncertain remote result before the job retries.
