Exporting files to Supabase Buckets in Ruby
To export a file to Supabase from Ruby, send its bytes to the Storage REST API, then download the selected object and compare it with the input. This example signs in as an ordinary user, refuses existing object paths, and leaves a verified upload in the bucket. Deletion is a separate command.
Prerequisites
- Ruby 4.0.7, Bundler, and a compiler and
maketo install the JSON gem. Ruby’s downloads page lists current releases and installation options. The Linux setup is tested with Bundler 4.0.20, and also with Ruby 3.3.12 and Bundler 2.5.22. - Bash for the shell commands below. Start from a writable directory with no existing
supabase-storage-demochild, and no exportedBUNDLE_*,GEM_HOME, orGEM_PATHoverrides. - A Supabase project, a confirmed email/password Auth user, and a dedicated private Storage bucket
named
ruby-exports. Follow the official bucket creation guide to create it; allow theapplication/octet-streamcontent type and files up to 1 MiB. - The project URL, project key, and user’s email and password supplied as environment variables.
For hosted projects, use a publishable project key.
It belongs in the apikey header; the signed-in user’s JWT belongs in Authorization: Bearer.
Supabase announces legacy anon and service_role key deprecation by the end of 2026.
Existing projects can use both key systems during migration. The native verification of this
example uses a local self-hosted project with a legacy anon key, not a hosted publishable-key
project. Hosted key provisioning and TLS have not been tested here.
Run the following SQL as the project administrator for that dedicated bucket. These
Storage policies allow uploads
under the user’s UUID and let that user read and delete their own objects. Upload needs INSERT;
readback needs SELECT. Deletion also needs SELECT and DELETE, as documented in the
Storage remove reference.
Check for existing broader policies first: permissive policies combine, so these rules do not
restrict access already granted by another policy.
create policy ruby_exports_insert on storage.objects
for insert to authenticated with check (
bucket_id = 'ruby-exports'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy ruby_exports_select on storage.objects
for select to authenticated using (
bucket_id = 'ruby-exports'
and (storage.foldername(name))[1] = (select auth.uid())::text
and owner_id = (select auth.uid())::text
);
create policy ruby_exports_delete on storage.objects
for delete to authenticated using (
bucket_id = 'ruby-exports'
and (storage.foldername(name))[1] = (select auth.uid())::text
and owner_id = (select auth.uid())::text
);
Supply SUPABASE_URL, SUPABASE_PROJECT_KEY, SUPABASE_EMAIL, and SUPABASE_PASSWORD through
your secret manager or protected process environment. Use the project’s root URL, such as
https://PROJECT_REF.supabase.co. Do not put passwords, user tokens, or administrative keys in
source code or shell history. The program obtains a fresh user session through
Auth’s password grant each time it runs; it does not
need a secret or service_role key.
Setting up your environment
Paste this block in Bash. The subshell keeps your current directory and shell options intact, including when setup fails. An existing project directory is preserved.
(
command -v ruby >/dev/null &&
command -v bundle >/dev/null &&
mkdir supabase-storage-demo &&
cd supabase-storage-demo &&
bundle init &&
bundle config set --local path vendor/bundle
)
Replace the newly created supabase-storage-demo/Gemfile with:
source 'https://rubygems.org'
gem 'json', '3.0.2'
gem 'net-http', '0.10.0'
The pins use the current JSON and
Net::HTTP releases checked on October 12, 2026.
Install them into this project’s vendor/bundle, without a system-wide gem installation:
(cd supabase-storage-demo && bundle install)
If installation fails, fix the reported network or compiler prerequisite and rerun that installation block in the same project. Keep its Gemfile and local Bundler configuration; do not repeat the directory-creation block or remove an existing project to retry.
Configuring a REST API client in Ruby
Save this complete program as supabase-storage-demo/storage.rb. It handles one file at a time,
including empty and binary files, with an application limit of 1 MiB. Local filenames must contain
valid UTF-8 bytes without control characters; validation does not depend on the shell’s locale.
Object keys use ASCII letters, digits, spaces, _, -, ., and /, with no empty, . or ..
folder segments. Choose a supported key independently of the local filename: the tested
Storage version rejects Unicode and percent signs in keys.
The program encodes each supported key segment for the URL.
require 'digest'
require 'json'
require 'net/http'
require 'uri'
class StorageError < StandardError
end
class SupabaseStorage
MAX_BYTES = 1024 * 1024
attr_reader :user_id
def initialize
@url = ENV.fetch('SUPABASE_URL').chomp('/')
origin = URI(@url)
secure = origin.is_a?(URI::HTTPS)
local = origin.is_a?(URI::HTTP) && %w[localhost 127.0.0.1 [::1]].include?(origin.host)
unless (secure || local) && origin.userinfo.nil? && origin.query.nil? &&
origin.fragment.nil? && origin.path.empty?
raise StorageError, 'Use an HTTPS project root URL, or HTTP on localhost for local testing'
end
@project_key = ENV.fetch('SUPABASE_PROJECT_KEY')
login = Net::HTTP::Post.new('/auth/v1/token?grant_type=password')
login['Content-Type'] = 'application/json'
login.body = JSON.generate(email: ENV.fetch('SUPABASE_EMAIL'),
password: ENV.fetch('SUPABASE_PASSWORD'))
session = JSON.parse(request(login).body)
@token = session.fetch('access_token')
user = JSON.parse(request(Net::HTTP::Get.new('/auth/v1/user')).body)
@user_id = user.fetch('id')
end
def upload(bucket, key, path)
bytes = input_bytes(path)
upload = Net::HTTP::Post.new(object_path(bucket, key))
upload['Content-Type'] = 'application/octet-stream'
upload['x-upsert'] = 'false'
upload.body = bytes
request(upload)
verify_bytes(bucket, key, bytes)
end
def verify(bucket, key, path)
verify_bytes(bucket, key, input_bytes(path))
end
def delete(bucket, key)
key = text(key)
object_path(bucket, key)
deletion = Net::HTTP::Delete.new("/storage/v1/object/#{segment(bucket)}")
deletion['Content-Type'] = 'application/json'
deletion.body = JSON.generate(prefixes: [key])
deleted = JSON.parse(request(deletion).body)
unless deleted.is_a?(Array) && deleted.any? { |object| object['name'] == key }
raise StorageError, 'Deletion was not confirmed; the object may be missing or inaccessible'
end
puts 'Deletion confirmed'
end
private
def request(message)
message['apikey'] = @project_key
message['Authorization'] = "Bearer #{@token}" if @token
uri = URI(@url)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 10, read_timeout: 30, write_timeout: 30) do |http|
http.max_retries = 0
response = http.request(message)
unless response.is_a?(Net::HTTPSuccess)
raise StorageError, "Request failed (HTTP #{response.code})"
end
response
end
end
def text(value)
value = value.dup.force_encoding(Encoding::UTF_8)
unless value.valid_encoding? && !value.match?(/[\x00-\x1f\x7f]/)
raise StorageError, 'Paths must be valid UTF-8 without control characters'
end
value
end
def segment(value)
URI.encode_www_form_component(text(value)).gsub('+', '%20')
end
def object_path(bucket, key)
raise StorageError, 'Invalid bucket name' unless bucket.match?(/\A[a-z0-9][a-z0-9-]*\z/)
key = text(key)
unless key.match?(/\A[A-Za-z0-9_.\/ -]+\z/)
raise StorageError, 'Use an ASCII object key with letters, digits, spaces, _, -, ., and /'
end
parts = key.split('/', -1)
if parts.any? { |part| ['', '.', '..'].include?(part) } || key.include?('\\')
raise StorageError, 'Use a nonempty object key with ordinary folder segments'
end
"/storage/v1/object/#{segment(bucket)}/#{parts.map { |part| segment(part) }.join('/')}"
end
def input_bytes(path)
path = text(path)
raise StorageError, 'Input must be a regular file' unless File.file?(path)
bytes = File.binread(path, MAX_BYTES + 1) || ''.b
raise StorageError, 'Input exceeds the 1 MiB application limit' if bytes.bytesize > MAX_BYTES
bytes
end
def verify_bytes(bucket, key, expected)
download = Net::HTTP::Get.new(object_path(bucket, key))
download['Accept-Encoding'] = 'identity'
actual = request(download).body || ''.b
unless actual.b == expected.b
raise StorageError, 'Stored bytes differ from the input; inspect the object before retrying'
end
puts "Verified #{expected.bytesize} bytes; SHA-256 #{Digest::SHA256.hexdigest(actual)}"
end
end
begin
command, bucket, key, path = ARGV
valid = (command == 'whoami' && ARGV.length == 1) ||
(%w[upload verify].include?(command) && ARGV.length == 4) ||
(command == 'delete' && ARGV.length == 3)
unless valid
raise StorageError, 'Usage: storage.rb whoami | upload/verify BUCKET KEY FILE | delete BUCKET KEY'
end
storage = SupabaseStorage.new
case command
when 'whoami' then puts storage.user_id
when 'upload' then storage.upload(bucket, key, path)
when 'verify' then storage.verify(bucket, key, path)
when 'delete' then storage.delete(bucket, key)
end
rescue StandardError => error
# Only our deliberate messages are safe to print; library errors can contain private data.
safe = error.instance_of?(StorageError) ? error.message : 'Check configuration, input, and connectivity'
warn "Storage command failed: #{safe}"
exit 1
end
The upload sends raw bytes, not JSON or multipart fields. Readback uses the authenticated object endpoint, with the same user’s permissions. Each network operation has a 10-second connection timeout and 30-second read/write inactivity timeouts, not a whole-transfer deadline. Automatic retries are disabled: a lost response can leave an object even though the command reports failure.
Usage examples
Check the supplied credentials and obtain the user UUID:
(cd supabase-storage-demo && bundle exec ruby storage.rb whoami)
Put a file named report.bin inside the project. Replace USER_UUID below with the printed UUID;
it must be the first folder in the object key for the policies above.
(cd supabase-storage-demo &&
bundle exec ruby storage.rb upload ruby-exports 'USER_UUID/exports/report.bin' ./report.bin)
A successful command prints the byte count and SHA-256 of the downloaded object and exits zero. The object remains at the selected key. To check it again without uploading or replacing it:
(cd supabase-storage-demo &&
bundle exec ruby storage.rb verify ruby-exports 'USER_UUID/exports/report.bin' ./report.bin)
Keep the source file unchanged while comparing it. Quote path arguments containing spaces, and use
./ for a local filename that starts with a hyphen. This example does not list folders or create
public download links.
Error handling and file validation
Every refused upload, failed readback, or unconfirmed deletion exits nonzero. Existing keys are refused by standard uploads; the program does not request upsert or replace the earlier bytes. Run commands sequentially.
An HTTP failure does not by itself identify the cause. Check the project URL, user credentials, bucket restrictions, key’s UUID prefix, and applicable policies. A missing or inaccessible object can have the same response. The program omits raw server bodies, credentials, and library errors from its diagnostic.
The 1 MiB limit includes its exact endpoint and permits zero bytes. It is this example’s limit;
your project and bucket can impose additional restrictions. The MIME header declares generic binary
content and does not recognize file formats or establish content safety. If upload succeeds but
verification fails, inspect that key using the verify command before retrying: the upload is not
rolled back or silently deleted.
Best practices
For recurring report exports, choose a new key for each report, for example
USER_UUID/exports/2026/10/report-12.bin. A date folder keeps exports organized; an additional
job identifier distinguishes multiple reports from the same day. Retain the key alongside the job
record so later verification and deletion target the same object.
Handling large files
This small-file example buffers both the input and readback, and cannot resume interrupted uploads. For larger transfers, use Supabase’s resumable upload protocol with a compatible tus client. Independent chunks sent to the standard object endpoint do not implement that protocol. Resumable transfers are outside this example’s verified scope.
Delete an export explicitly
After the exported file is no longer needed, delete only its selected key:
(cd supabase-storage-demo &&
bundle exec ruby storage.rb delete ruby-exports 'USER_UUID/exports/report.bin')
Success requires the deletion response to identify that object. An empty array can mean it is missing or hidden by policies, so the command reports failure in that case. Deleting one export does not remove its bucket, other objects, users, or policies.
