← All posts

Image Uploads and Derivatives with Shrine

September 22, 2021 rails

Shrine is a file upload library for Ruby that handles the full lifecycle — uploading, processing, storage, and cleanup. Unlike Active Storage, Shrine stores metadata and file references in a regular database column, which makes querying and managing attachments straightforward.

This post sets up Shrine for image uploads with automatic thumbnail generation using ImageMagick. We’ll configure two storage backends (local for development, S3 for production), define image derivatives, and wire it into a Rails model and form.

Install the Gems

# Gemfile
gem "shrine", "~> 3.0"
gem "image_processing", "~> 1.8"
bundle install

image_processing wraps ImageMagick (or libvips) in a clean Ruby API. It’s what generates the thumbnails.

Configure Shrine

config/initializers/shrine.rb:

require "shrine"
require "shrine/storage/file_system"

Shrine.plugin :activerecord
Shrine.plugin :cached_attachment_data
Shrine.plugin :restore_cached_data
Shrine.plugin :validation
Shrine.plugin :validation_helpers
Shrine.plugin :determine_mime_type
Shrine.plugin :derivatives

Shrine.storages = {
    cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
    store: Shrine::Storage::FileSystem.new("public", prefix: "uploads")
}

Shrine uses two storages: cache for temporary uploads (before the record is saved) and store for permanent files.

The plugins:

  • activerecord — ActiveRecord integration
  • cached_attachment_data — preserves uploads across form re-renders
  • derivatives — multiple versions (thumbnails, etc.) from one upload
  • determine_mime_type — detects real MIME type from file content
  • validation_helpers — file type and size validators

The Uploader

app/uploaders/image_uploader.rb:

require "image_processing/mini_magick"

class ImageUploader < Shrine

    Attacher.validate do
        validate_max_size 10 * 1024 * 1024, message: "is too large (max 10 MB)"
        validate_mime_type %w[image/jpeg image/png image/webp], message: "must be a JPEG, PNG, or WebP"
    end

    Attacher.derivatives do |original|
        magick = ImageProcessing::MiniMagick.source(original)

        {
            small: magick.resize_to_limit!(200, 200),
            medium: magick.resize_to_limit!(500, 500),
            large: magick.resize_to_limit!(1200, 1200)
        }
    end
end

Attacher.validate runs on every upload — rejecting files that are too large or the wrong type before they’re stored.

Attacher.derivatives defines the thumbnail sizes. resize_to_limit! scales the image down to fit within the given dimensions while maintaining aspect ratio. An 800×600 image processed with resize_to_limit!(500, 500) becomes 500×375.

The three sizes cover common use cases: small for list views and cards, medium for detail pages, large for lightboxes or hero images.

The Migration

Add a column to your model. Shrine stores everything — the original file reference, derivatives, and metadata — in a single JSON column:

rails generate migration AddImageDataToProducts image_data:jsonb
class AddImageDataToProducts < ActiveRecord::Migration[7.0]

    def change
        add_column :products, :image_data, :jsonb
    end
end
rails db:migrate

The Model

app/models/product.rb:

class Product < ApplicationRecord

    include ImageUploader::Attachment(:image)
end

That one line gives the model image, image_url, image_derivatives, and several other methods. Shrine uses the column name (image_data) to infer the attachment name (image).

Generating Derivatives

Derivatives can be generated inline (during the request) or in a background job. For most cases, inline is simpler:

class ProductsController < ApplicationController

    def create
        @product = Product.new(product_params)
        if @product.save
            @product.image_derivatives! if @product.image
            redirect_to @product
        else
            render :new, status: :unprocessable_entity
        end
    end

    def update
        if @product.update(product_params)
            @product.image_derivatives! if @product.image_attacher.changed?
            redirect_to @product
        else
            render :edit, status: :unprocessable_entity
        end
    end

    private

    def product_params
        params.require(:product).permit(:name, :description, :image)
    end
end

image_derivatives! processes the original upload and creates all three sizes. In update, we only regenerate when the image actually changed.

The Form

<%= form_with model: @product do |f| %>
  <%= f.hidden_field :image, value: @product.cached_image_data, id: "product_image_hidden" %>

  <div class="mb-3">
    <%= f.label :image, class: "form-label" %>
    <%= f.file_field :image, accept: "image/jpeg,image/png,image/webp", class: "form-control" %>
  </div>

  <% if @product.image %>
    <div class="mb-3">
      <%= image_tag @product.image_url(:medium), class: "img-thumbnail" %>
    </div>
  <% end %>

  <%= f.submit class: "btn btn-primary" %>
<% end %>

The hidden field preserves the cached upload when the form re-renders due to validation errors. Without it, the user would have to re-select the file after fixing a validation error on another field.

Displaying Images

<%# Original %>
<%= image_tag @product.image_url %>

<%# Specific derivative %>
<%= image_tag @product.image_url(:small) %>
<%= image_tag @product.image_url(:medium) %>
<%= image_tag @product.image_url(:large) %>
Let’s work together! Tell Me About Your Project