← Blog

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.

Campsend sends files to one person through an expiring link, and tells you when it was opened.

Send files