Building a file-sharing service with Rails 8 and SQLite
Campsend sends finished work to one person, tells you when they opened it and lets you keep the files in a bucket you own. It runs on one server.
I don't work on this full time, and Campsend is meant to be self-hostable. Fewer services helps both.
Here's what's running, and where it stops being enough.
Why Rails 8
Start with what isn't there.
Authentication is the clearest example. Rails 8 generates it. There's no Devise and no password to store. A sign-in link goes to an email address, and the session holds a user id and a timestamp:
module Authentication
extend ActiveSupport::Concern
SESSION_LIFETIME = 30.days
included do
before_action :require_authentication
helper_method :current_user, :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def current_user
return if session[:authenticated_at].to_i < SESSION_LIFETIME.ago.to_i
@current_user ||= User.find_by(id: session[:user_id])
end
end
That's a concern, included once in ApplicationController. Every controller is authenticated by default and opts out explicitly:
class DeliveriesController < ApplicationController
allow_unauthenticated_access
end
A delivery recipient never signs in. That one line is what makes their page reachable.
Background jobs are Solid Queue, which runs inside Puma:
SOLID_QUEUE_IN_PUMA: true
Solid Queue stores jobs in the database. Nothing runs alongside Puma and there's no Redis. The delivery email is a job, and its retry rules sit on the class:
class DeliveryEmailJob < ApplicationJob
DELIVERY_ERRORS = [ Net::SMTPServerBusy, Net::SMTPUnknownError, Net::OpenTimeout, Net::ReadTimeout, SocketError ].freeze
discard_on ActiveJob::DeserializationError
retry_on(*DELIVERY_ERRORS, wait: 10.seconds, attempts: 3) do |job, _error|
delivery = job.arguments.first.reload
delivery.update!(email_status: "failed") if delivery.publication_pending?
end
end
Caching is Solid Cache and websockets are Solid Cable. Both keep their data in the database. Neither adds anything to run.
Here's the whole production Gemfile:
gem "rails", "~> 8.1.3"
gem "propshaft"
gem "sqlite3", ">= 2.1"
gem "puma", ">= 5.0"
gem "importmap-rails"
gem "turbo-rails"
gem "stimulus-rails"
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
gem "kamal", require: false
gem "thruster", require: false
gem "aws-sdk-s3", require: false
gem "mcp", "~> 1.3"
Fourteen gems. Twelve of them come with rails new. The two I added are aws-sdk-s3 and mcp.
Why SQLite
Rails 8 made SQLite a production database rather than a development convenience. WAL mode is on. IMMEDIATE transactions are the default, which removes the write-lock error that used to make people give up on it. The busy timeout is set for you.
Campsend runs four SQLite databases on one machine:
production:
primary:
database: storage/production.sqlite3
cache:
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate
Four files in a mounted volume. Backing up and restoring are both a file copy.
It works here because the database holds metadata and nothing else. The files go to object storage. Sending a delivery writes a few rows and reading one back is a few indexed queries. One machine manages that without effort.
This holds while writes are low and there's one machine. Concurrent writes serialize. There's no second host to fail over to. If Campsend reaches the point where a single writer is the constraint, the answer is Postgres and a migration, and Rails makes that a config change plus a data move.
I'd rather run the simple thing now and pay that cost when the numbers say to, than run Postgres for a workload that doesn't need it.
Your own storage
Campsend lets you point it at an S3-compatible bucket you already own: R2, S3, Backblaze B2.
Active Storage already resolves a service per blob, so the choice can be made per upload:
def reserve_blob!(**attributes)
with_lock do
Campsend.policy.admit_storage(user: self, byte_size:) do
key = "#{Campsend.policy.storage_key_prefix_for(user: self)}/#{ActiveStorage::Blob.generate_unique_secure_token}"
attributes[:service_name] = Campsend.policy.storage_service_name_for(user: self)
ActiveStorage::Blob.create_before_direct_upload!(key: key, **attributes)
end
end
end
storage_service_name_for returns nil by default, which means the configured default service. When somebody has connected a bucket it returns user_s3, and a service class resolves the credentials per key:
class Service::UserS3Service < Service
def upload(key, io, **options)
with_service(key) { |service| service.upload(key, io, **options) }
end
def download(key, &block)
with_service(key) { |service| service.download(key, &block) }
end
end
The key carries the user id, which is how the service finds the right credentials. Credentials are encrypted with encrypts, and the endpoint is checked against private network ranges before anything connects to it:
class StorageAccount < ApplicationRecord
encrypts :access_key_id, :secret_access_key
validate :endpoint_is_safe, if: :will_save_change_to_endpoint?
end
That validation exists because a field where somebody types a URL, which the server then requests, is a request-forgery hole if you leave it open.
The other reason this matters is cost. Storage is cheap now in a way that changes what a small product can offer. R2 is $0.015 per GB-month with no egress charge. S3 Standard is $0.023 per GB-month plus $0.09 per GB out. At 100 GB stored and delivered, that's $1.50 against $2.30 plus egress.
A file-sharing service charging by the gigabyte is charging for something that costs almost nothing. So Campsend charges per workspace, and if you'd rather pay Cloudflare directly, you can.
Sending from an agent
Campsend could send a file from Claude before it could send one from a shell script. That ordering was an accident, and the agent half turned out to be more interesting.
It's an MCP server, which in Rails is one controller action:
module Mcp
class ServerController < ApplicationController
TOOLS = [ Agent::ListDeliveries, Agent::GetDelivery, Agent::CreateDelivery ].freeze
allow_unauthenticated_access
skip_forgery_protection
before_action :require_api_token
rate_limit to: 120, within: 1.hour, by: -> { @api_token.id }
def create
response_json = build_server.handle_json(request.body.read)
return head :accepted if response_json.nil?
render json: response_json
end
end
end
One JSON-RPC request in, one JSON response out. No SSE and no session handling, because none of the tools stream.
A tool is a class with a schema:
class Agent::CreateDelivery < MCP::Tool
tool_name "create_delivery"
description "Send files you have already uploaded to one recipient. Campsend emails them a link that expires after 30 days."
annotations(read_only_hint: false, destructive_hint: false, idempotent_hint: false)
def self.call(recipient_email:, file_ids:, server_context:, **options)
token = server_context.fetch(:api_token)
return Agent::Response.failure("This token can only read. Create one that can read and send.") unless token.writable?
user = server_context.fetch(:user)
delivery = user.sends.new(recipient_email: recipient_email, files: owned_blobs(user, file_ids))
return Agent::Response.failure(delivery.errors.full_messages.to_sentence) unless delivery.deliver!
Agent::Response.ok(Agent::DeliveryPresenter.detail(delivery.reload))
end
end
The tool calls deliver!, the same method the web form calls. An agent can't reach a path the UI can't. And tokens are scoped, so a read-only token gets a refusal it can act on rather than a 403.
The descriptions are written for a model rather than for a developer. "Files you have already uploaded" and "expires after 30 days" are there because a model needs the constraint in the description; it won't go and read the docs.
The whole stack
One server, four SQLite files in a volume and fourteen gems, shipped with kamal deploy.
I'm not claiming this scales to a million users, and I haven't had to find out. The version of this stack that needs Postgres, Redis, Sidekiq and a second machine is one most products never reach.
Campsend is open source under MIT. The code for everything here is on GitHub, and you can try the hosted version or run it yourself.