Every year we run Friendly.rb, and every year a photographer hands us a folder with somewhere between four hundred and nine hundred photos in it. For three editions those photos lived on a hosted gallery service. It worked. It also owned the URLs, owned the layout, and shipped a section of 342 thumbnails as 2.75 MB of HTML, which is roughly a novel's worth of markup to show you some pictures of people eating lunch.

So we replaced it. friendlyfolio is a Rails 8.1 app on SQLite, one box, Avo for the admin, and it now serves all 1,916 photos across three editions at photos.friendlyrb.com. We open sourced it under MIT, so everything below is checkable against the code rather than something you have to take our word for.

This is not a tutorial about attaching an image to a model. There are plenty of those, and we wrote one of them. This is what we learned in the gap between "Active Storage can make variants" and "Active Storage is serving two thousand photos to strangers." Most of the surprises live in that gap, and the guides do not mention them.

The shape of the problem

Three editions. Thirteen sections. 1,916 photos, most of them 6000x4000 exports straight out of a photographer's Lightroom, about 15 GB of originals.

The stack is deliberately boring: Rails 8.1 on SQLite, Solid Queue for jobs, importmap and the standalone Tailwind binary so there is no Node in the picture, Avo for the admin, one box behind Caddy. Nothing here needs a second server, and a large part of the work was making sure it stays that way.

A photo gallery looks like the easiest possible Rails app, and the model layer really is: a Gallery has many Sections, a Section has many Photos, a Photo has one attached image. You could write that in an afternoon.

What is not easy is that a gallery is one of the few apps where the payload is the product. Nobody visits to read your HTML. They came for 300 photographs, and every decision you make either gets those photographs onto their screen or gets in the way.

Nine derivatives: AVIF, WebP, and why none of them is a JPEG

The variant ladder is the first real decision:

class Photo < ApplicationRecord

VARIANTS = {

thumb_avif: { resize_to_limit: [400, 400], format: :avif, saver: { Q: 50, effort: 4, strip: true } },

thumb_webp: { resize_to_limit: [400, 400], format: :webp, saver: { Q: 72, strip: true } },

wall_avif: { resize_to_limit: [800, 800], format: :avif, saver: { Q: 50, effort: 4, strip: true } },

wall_webp: { resize_to_limit: [800, 800], format: :webp, saver: { Q: 72, strip: true } },

zoom_avif: { resize_to_limit: [1280, 1280], format: :avif, saver: { Q: 52, effort: 4, strip: true } },

zoom_webp: { resize_to_limit: [1280, 1280], format: :webp, saver: { Q: 78, strip: true } },

large_avif: { resize_to_limit: [2048, 2048], format: :avif, saver: { Q: 52, effort: 4, strip: true } },

large_webp: { resize_to_limit: [2048, 2048], format: :webp, saver: { Q: 78, strip: true } },

share_jpeg: { resize_to_limit: [2048, 2048], format: :jpeg, saver: { quality: 80, strip: true } }

}.freeze

end

Four sizes, two modern formats, served through <picture> so the browser picks. AVIF reached Baseline "widely available" on 2026-07-25, thirty months after Edge 121 became the last major browser to ship it, and caniuse currently puts global support at 95.4%. That is what finally made a JPEG display tier dead weight: a third format would cost encode time and disk and reach almost nobody that WebP does not already reach.

The ninth derivative is the odd one. share_jpeg is not a display tier and never appears in a <picture>. It exists for the two places where a modern format is a liability. One is the "download for sharing" button, where the file lands on someone else's machine and their software has to open it. The other is og:image, because link unfurlers on Slack, LinkedIn and X will render precisely nothing for an AVIF. That is a 404-shaped hole in your social previews that no test will catch.

Was AVIF worth it? We measured the live gallery rather than guessing. Twenty tiles from the 2025 day-1 wall, each one's 400px AVIF against the same photo's 400px WebP:

A median saving of 31.6%, a mean of 33.7%, a worst case of 22.5%, and not one of the twenty where AVIF came out larger. On a wall of 300 tiles that is the difference between a page that feels instant and one that does not.

Before you build a ladder like this, add one line of configuration:

# config/initializers/active_storage.rb

config.active_storage.web_image_content_types = %w[

image/png image/jpeg image/gif image/webp image/avif

]

That setting decides the default output format for a variant. With load_defaults 8.1 the list you actually get is PNG, JPEG, GIF and WebP. AVIF is the one missing. The chain in ActiveStorage::Blob::Representable is short:

def default_variant_format

if web_image?

format || :png

else

:png

end

end

So an AVIF or HEIC source is not a "web image" as far as Active Storage is concerned, and any variant you ask for from one without an explicit format: comes back as a PNG. Every variant above names its format, and default_to is a reverse_merge, so an explicit format always wins and our ladder was never actually at risk. We widen the list anyway. The first variant somebody adds without a format is the one that quietly starts shipping PNGs into a page that looks entirely correct, and the defaults make that easy: variable_content_types already includes image/avif, image/heic and image/heif, so those uploads are perfectly variable. Only the output default has not caught up, which is exactly the combination that produces a silent PNG rather than an error.

Baking, and why preprocessed: true is a trap at this size

Active Storage generates a missing variant synchronously, inside the web request. The representations controller calls .processed in a before_action, and both the proxy and the redirect controllers inherit it. So a visitor who loads a wall of 300 tiles whose derivatives do not exist yet has just triggered 300 libvips invocations inside your Puma workers. On one box, that is the whole box.

Every derivative has to exist before anything renders, then. The trap is how you make that happen.

Rails offers preprocessed: true on a named variant, and it looks exactly like the answer. It is not, at this size. preprocessed: is a property of the attachment declaration, not of the call site. Active Storage runs transform_variants_later in an after_create_commit on every attach. Declare nine preprocessed variants and every attach enqueues nine jobs. Across 1,916 photos that is roughly 17,250 jobs landing in a SQLite queue while the import is still inserting photos.

To be fair to it, preprocessed: is not strictly all-or-nothing. ActiveStorage::NamedVariant#preprocessed? accepts a Symbol or a Proc and evaluates it against the record, so you can gate baking per record:

attachable.variant :thumb_avif, preprocessed: ->(photo) { !photo.bulk_imported? }, **transformations

What you cannot do is decide at the attach call site, which is where a bulk import actually knows the answer. Threading an import flag onto every record so nine lambdas can read it back is more machinery than calling the bake yourself, so baking is driven explicitly instead, and the two paths are different on purpose:

# One job per photo when someone uploads through the admin.

after_commit :refresh_image_derivatives, on: [:create, :update], if: :image_changed?

def bake_variants!

if has_location_data?

raise LocationDataPresent,

"#{download_filename} still carries GPS EXIF. Strip it from the source first: " \

"exiftool -gps:all= -overwrite_original <dir>"

end

VARIANTS.each_key { |name| image.variant(name).processed }

update!(derivatives_ready_at: Time.current)

end

# Nothing renders until the derivatives exist.

scope :displayable, -> { where.not(derivatives_ready_at: nil) }

The bulk path runs the same .processed loop itself, under the rake task's own small thread pool, and the ingest step installs a before_enqueue guard that aborts TransformJob, AnalyzeJob and our own bake job rather than queueing 17,250 rows of work it is about to do itself. The analyze jobs mattered more than we expected. The bake runs on a laptop and the SQLite files travel to the server afterwards, so a queue full of unrun analyze jobs would have been rsynced to production and dequeued there, pulling every original back out of storage to read its header.

The displayable scope is three words and it is the entire guarantee: a photo added to a live gallery is invisible until its derivatives are on disk, so no visitor ever becomes the process that generates them. A photos:verify task checks that guarantee before publishing, and it asks the storage service directly with exist?, because a verifier built on .processed silently repairs whatever it was asked to report and can never fail.

image_changed? is the other half, and we got it wrong first:

def image_changed?

return false unless image.attached?

derived_from_blob_id != image.blob.id

end

Our first version checked width.nil?. It was wrong in a way that took a while to see. Replacing a live photo's image in the admin skipped both the re-bake and the GPS check, because the derived columns were already populated from the old file. The new photo kept the old dimensions, kept has_location_data false, and the download button handed out the new original with its coordinates intact. Key the callback on the blob actually changing.

How long does baking take? Measured on a 10-core M-series laptop against 6000x4000 sources, 12.3 seconds per photo for all nine variants, which puts the full 1,916-photo corpus at about six and a half hours. Two thirds of that is AVIF encoding and the 2048px AVIF alone is six seconds of it. Adding Ruby threads did not help: one worker took 14.6 seconds per photo, two took 12.3, four took 13.7, because past two the pool is fighting libvips for the same cores and fighting itself for SQLite's single writer. Before the writes were wrapped in a retry, a four-worker run lost three photos out of four to SQLITE_BUSY. Two workers, libvips left to thread across the cores, done.

It runs once per edition, it is restartable, and it runs on a laptop rather than on the server, because libvips competing with Puma for cores on a single box is how you turn an import into an outage. It is the same lesson as exporting a large table to CSV: the naive version works perfectly until the row count makes it the only thing your server is doing.

Two placeholders, not one

We publish active_storage-blurhash and we have written up how to use it. Building friendlyfolio taught us two things about our own gem, and neither is in that post.

The first: one placeholder strategy is the wrong number.

A blurhash is a lovely thing at full-screen. It is also a <canvas> per image, decoded in JavaScript, converted to a data URL. On a wall of 300 tiles where every tile is 400px and its space is already reserved by an aspect-ratio, nobody has ever noticed the difference between a blurred approximation and a flat rectangle of the right color. So the wall gets a single color, derived in one vips call:

# Shrink the whole image to one pixel and read it.

def dominant_color(path)

pixel = Vips::Image.thumbnail(path, 1, height: 1).colourspace(:srgb)

r, g, b = pixel.getpoint(0, 0).first(3).map { |v| v.to_i.clamp(0, 255) }

format("#%02x%02x%02x", r, g, b)

rescue StandardError

"#e5e5e5"

end

The blurhash is reserved for the lightbox, where one photo fills the screen and a flat color looks like a bug. Lazily loaded tiles get it painted over the flat color as they approach the viewport, which is an IntersectionObserver rather than a loop on connect:

connect() {

this.observer = new IntersectionObserver(

(entries) => this.paintVisible(entries),

{ rootMargin: "200px" }

)

this.element.querySelectorAll("[data-blurhash]").forEach((el) => this.observer.observe(el))

}

One controller on the container, not 300 controllers. Decoding every tile up front is work nobody scrolls far enough to see, and toDataURL is not free at that count.

The second: we did not use our own gem's Active Storage integration. We use the blurhash gem to encode and the blurhash package to decode, but the analyzer that ties them to Active Storage is bypassed. The reason is written into the source where it happens:

That gem's analyzer hands vips the path of an ImageProcessing tempfile and drops the

Tempfileitself; vips opens it lazily, so GC unlinks the file before the pixels are read and every job on a busy worker dies with "unable to open for read."

On a quiet development machine you will never see it. Under an import that is keeping the GC busy, it is most of your queue. So friendlyfolio derives the hash in the same pass that already has the original open on disk for dimensions and dominant color, which also saves downloading the original a second time:

# 32px is generous: the hash is 4x3 components, so everything

# finer is thrown away by the encoder anyway.

def blurhash(path)

thumb = Vips::Image.thumbnail(path, 32).colourspace(:srgb)

thumb = thumb.extract_band(0, n: 3) if thumb.bands > 3

Blurhash.encode(thumb.width, thumb.height, thumb.to_a.flatten)

rescue StandardError

nil

end

That comment overstates its case. BlurHash's encoder loops over every pixel you hand it, so nothing is discarded for free and a bigger input costs proportionally more time. What throws detail away is the output: at 4x3 components only twelve low-frequency coefficients survive, which works out to exactly 28 characters (one for the size flag, one for the quantized maximum, four for the DC term, two for each of the eleven AC terms). Downscaling first is a performance decision whose effect on the result is negligible, not the encoder ignoring your pixels. The advice is the same either way: encode from a thumbnail.

The tempfile bug is a bug report against our own gem, filed in production code instead of an issue tracker. We would rather publish it than have you find it at 1,900 photos.

A justified wall in twenty lines of CSS, and the srcset trap

The photo wall is the one piece that came out smaller than expected. No layout library, no measuring pass in JavaScript, no masonry plugin. Each tile stores its aspect ratio as a CSS custom property and flexbox does the rest:

.wall {

display: flex;

flex-wrap: wrap;

gap: var(--gap);

--h: 240px;

}

.tile {

flex: var(--r) 1 calc(var(--r) * var(--h));

aspect-ratio: var(--r);

}

/* Stops the final row stretching its tiles to absurd widths. */

.tile-spacer { flex-grow: 1e4; height: 0; }

Because each tile's flex-grow is proportional to its aspect ratio, every tile in a row resolves to the same height and the row justifies flush. Three zero-height spacers at the end absorb the slack so the last row does not stretch four photos across the viewport.

The result: a 300-tile section is 352 KB of HTML, 63 KB gzipped. The hosted product we replaced shipped 2.75 MB for a comparable 342-tile page.

The <picture> inside each tile:

<picture>

<source type="image/avif" srcset="<%= url %>" sizes="<%= sizes %>">

<img src="<%= url %>" sizes="<%= sizes %>" width="<%= photo.width %>" height="<%= photo.height %>">

</picture>

sizes is written on the <source> as well as the <img>, and that is the trap: a <source> does not inherit it, and a missing sizes silently means 100vw, so the browser fetches the largest candidate on a page that otherwise looks entirely correct. Our tiles ship one candidate per source today, so sizes is doing nothing yet. It is on both elements anyway, because a four-size ladder is asking for width descriptors, and whoever adds them will not remember this paragraph.

The CDN is architecture, not an optimization

On a single box, a CDN in front of the images is not a performance tweak you add later. It is load-bearing.

There is no Active Storage URL on a disk service that bypasses Rails. public: true only removes the expiry from the signed token, and proxy mode streams through ActionController::Live, chunk by chunk into the response, so there is never a file path for Rack::Sendfile to hand to the web server. Without a CDN, your box serves every byte of every wall view, forever.

So the images moved to Cloudflare R2 with a custom domain in front. We have already written up putting a CDN in front of Active Storage uploads and wiring Active Storage to Cloudflare R2, so this is not the setup walkthrough. It is the four things that only showed up once nearly two thousand photos were behind it.

The URL helper reads the variant's stored key directly:

def photo_variant_url(photo, variant)

representation = photo.image.variant(variant)

host = Rails.configuration.x.image_host

key = representation.key if host.present?

key.present? ? "#{host}/#{key}" : rails_storage_proxy_path(representation)

end

#processed is the only method on a variant that will actually build one. #key, #url and #download all delegate to the stored variant record and return nil if it does not exist yet, so none of them can be the thing that drags libvips into a request. What #key does cost you is a database lookup, which is the third problem below. A nil key falls back to the proxy path, which is also what makes the app work unchanged on local disk in development.

R2 does not implement S3 ACLs. Set public: true on the service and Active Storage does @upload_options[:acl] = "public-read", which then rides along on every put. Cloudflare's S3 compatibility table marks x-amz-acl as not implemented, and what we observed is worse than an error: the PUT came back fine and the object was not there. An R2 bucket is made readable by a custom domain or an r2.dev subdomain, never by an ACL, so public: true is the wrong switch entirely and the public URLs get built by our own helper instead. While you are in the service config: the aws-sdk now sends a CRC32 alongside the MD5 Active Storage already computes, and R2 answers "You can only specify one non-default checksum at a time", so set request_checksum_calculation and response_checksum_validation to when_required.

Never set urls_expire_in, and treat SECRET_KEY_BASE as permanent. With no expiry, the signed id carries "exp": null and is byte-identical every time you generate it. Set one and it embeds an absolute timestamp computed at generation, so every render emits a different URL: zero cache hits, and a CDN slowly filling with URLs that will later 404. The secret matters for the same reason. A proxy URL carries two signatures, the blob's signed id and the variation key, and both come from secret_key_base, so rotating it does not just log everyone out. Every proxy URL anyone has shared or a CDN has cached stops resolving. We had the rest of this backwards in the first draft of this post: the derivatives themselves survive a rotation. With track_variants on, a variant record is found by an unsigned digest of its transformations and stored under its own random key, so the bare CDN URLs keep working. That is one more argument for serving the key directly, and no reason to relax about the secret.

Moving to CDN URLs created an N+1 that proxy URLs never had. A proxy URL is built from the blob's signed id and the variation key, so it never reads the variant record. A CDN URL is the variant's key, which lives on that record. Suddenly each tile wants the record, its attachment and its blob, which is three queries per variant and roughly 1,800 on a 300-tile wall.

We wrote our own eager-load for this:

scope :with_images, -> {

includes(image_attachment: { blob: { variant_records: { image_attachment: :blob } } })

}

That was a wasted morning. Rails already generates exactly that. has_one_attached :image defines a with_attached_image scope, and when ActiveStorage.track_variants is on, which is the default, it eager-loads the variant records and their attachments and blobs for you:

scope :"with_attached_#{name}", -> {

if ActiveStorage.track_variants

includes("#{name}_attachment": { blob: {

variant_records: { image_attachment: :blob },

preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } }

} })

else

includes("#{name}_attachment": :blob)

end

}

So Photo.displayable.with_attached_image is the whole fix, and our hand-rolled version differs only by skipping the preview-image chain that photographs never have. Reach for the generated scope. This regression does not show up in a test and does not show up on a development gallery with six photos, and the fix is a scope you already have.

The fourth thing is the one that made this section worth writing. We set the header on every object:

upload:

cache_control: "public, max-age=31536000, immutable"

Active Storage keys are not content hashes, whatever you may have read, including in a comment in our own config/storage.yml. A blob key is a random 28-character base36 token, and a tracked variant gets one of its own. What makes one safe to cache forever is not the content, it is that a key is generated once and never reassigned, so the bytes behind a given key never change. We wrote the header, deployed, and moved on.

While writing this post we ran the check we tell everyone else to run:

curl -sI https://img.friendlyrb.com/<key> | grep -iE "cache-control|cf-cache-status"

# cache-control: public, max-age=31536000, immutable

# cf-cache-status: DYNAMIC

DYNAMIC means Cloudflare is not caching it. The header was necessary and not sufficient, and the reason is a two-stage gate people collapse into one. Cloudflare decides eligibility by file extension, from a fixed list of 56, and states it plainly: "Cloudflare only caches based on file extension and not by MIME type." Only for an eligible resource does Cache-Control then decide whether and how long to cache. A derivative on the bucket sits under a bare key, b8g3a5ytq9su0ngvthypd16bio09 say, with no extension at all, so it never reaches stage two however perfect the header is. The documented remedy is a Cache Rule.

Which URL this applies to is easy to get backwards. Active Storage's proxy route ends in *filename, so a proxy URL does carry .jpg or .webp and does hit the default list. It is the raw object URL on the bucket domain, built from the key, that has nothing for Cloudflare to match on. Moving to CDN URLs is exactly what moved us from one case to the other, which is why a setting that had been fine stopped being fine without anything visibly changing.

Cloudflare's own dashboard says as much, in the small print under the setting you are about to need:

Mark whether the request's response from origin is eligible for caching. Caching itself will still depend on the cache-control header and your other caching configurations.

Eligibility and caching are two different decisions, and we had only ever made the second one.

The fix, and what it measured

One Cache Rule on the zone, which took about ninety seconds:

Rule name Cache R2 image derivatives

Expression http.host eq "img.friendlyrb.com"

Then

Cache eligibility Eligible for cache

Edge TTL Use cache-control header if present, bypass cache if not

That is the whole rule. It sets no TTL of its own. The origin already sends max-age=31536000, immutable on every object, and the rule's job is only to make the response eligible so that header finally counts for something. Picking "bypass cache if not" over Cloudflare's default TTL is deliberate too. If a derivative ever shows up without a cache-control header, that is a bug we want to see as a cache miss, not a response silently pinned at the edge for an hour.

Before, three fetches of the same derivative:

cf-cache-status: DYNAMIC

cf-cache-status: DYNAMIC

cf-cache-status: DYNAMIC

After deploying the rule, the same URL:

cf-cache-status: MISS

age: 1 cf-cache-status: HIT

age: 2 cf-cache-status: HIT

age: 3 cf-cache-status: HIT

Then five more derivatives sampled from a different gallery, all HIT, all still carrying cache-control: public, max-age=31536000, immutable. The header was right the whole time. Nothing was listening to it.

Fetch a derivative twice and look at cf-cache-status. Do not assume, the way we did, that sending the right header is the end of the job. DYNAMIC on a URL you believe is cacheable is the CDN telling you it never even considered it.

The admin is Avo, and the gate is GPS

The back office is Avo, which is the part of this we did not have to build. Four resources, one custom tool, done. It is mounted inside a Devise authenticate block rather than behind a before_action:

authenticate :user do

mount_avo do

get "import_photos", to: "tools#import_photos", as: :import_photos

end

end

The difference is small and worth having: signed out, /avo does not exist as a route at all, rather than existing and redirecting.

The custom tool is a folder drop. You pick a gallery, drag in a directory, and the files direct upload straight to storage so gigabytes never pass through a Rails request. The endpoint only turns an already-uploaded blob into a Photo. Re-dropping the same folder is safe because imports are deduplicated by content digest, and the folder's own structure picks the sections: a file arriving as day-1/DSC_0001.jpg lands in a day-1 section, created if it does not exist.

One rule outranks all of it. A photo carrying GPS EXIF is refused, not imported.

def located?(image)

image.get_fields.grep(/gps/i).any?

rescue StandardError

false

end

The reasoning is the download button. It hands out the photographer's original file, so stripping metadata from the derivatives alone would still leak an attendee's location to anyone who downloads a photo of them. Stripping GPS losslessly needs exiftool, which we deliberately did not make a runtime dependency, so the app detects and refuses rather than silently re-encoding a photographer's file and losing quality. The runbook strips it first:

exiftool -gps:all= -overwrite_original /path/to/photos

Refusing loudly beats importing quietly. A conference gallery is a few hundred people who did not individually consent to being geolocated.

The two things we did not solve

Unpublishing a gallery closes every route this app serves for it. It does not revoke image URLs that are already out there.

Active Storage's proxy controller verifies the signed blob id and knows nothing about publication, so a derivative URL someone captured while the gallery was public keeps working, and a CDN in front will happily keep serving it. We could not close that, so we wrote a test that asserts it:

test "a variant URL captured while published still resolves after unpublishing" do

get @variant_url

assert_response :success

@gallery.update!(published_at: nil)

get @variant_url

assert_response :success,

"if this ever starts failing, Active Storage gained a publication-aware check"

end

A test that asserts a limitation is worth more than a comment describing one, because the comment cannot tell you when the limitation goes away. The practical consequence is a rule: review unpublished galleries locally, not on the deployed site, because previewing one pushes its full-size bytes into the CDN at permanent public URLs. If a photo genuinely has to come down, delete it. Unpublishing is not enough.

The other one is backups, and it is the honest weak spot. Hatchbox backs up the SQLite databases, which is the easy half. The originals alone are 15 GB. They live in R2 now, and the disk copy they were migrated from was left on the box on purpose, because it is the only other copy anything has. That is two copies and zero backups. Right now our answer is that the photographer still has the originals, which is a fact rather than a plan. If you build this, decide that before your first edition goes up, not after.

If we did it again

-

Grep Rails before writing an includes.with_attached_imagewas there the whole time, variant records included. The morning we spent onwith_imagesis the cheapest lesson in this post.

-

Run the two-fetch check the day the CDN goes live. We ran it while writing this and found DYNAMICon every derivative. ACache-Controlheader is a request, and the CDN has to be told to consider it.

-

Key every derived thing on the blob, not on a blank column. The width.nil?version let a replaced photo skip both the re-bake and the GPS check.

- Decide where the bytes are backed up before the first edition goes live. We still have not, and 15 GB in a bucket plus a copy on a disk is not a backup.

The whole thing is at github.com/Friendlyrb/friendlyfolio, MIT, including the plan document and the review findings that argue with the plan. If you are building something that serves a lot of images out of Active Storage, the runbook in the README is the part worth stealing.

Have a good one and happy coding!