Processing images with ActiveStorage and Imgproxy

4 minute read

ActiveStorage has a nifty feature that allows you to serve variants of uploaded images. Think of a user uploading a profile image. You won’t need the full-sized original most of the time. Instead, you can serve smaller versions of that image, which, of course, consume less bandwidth and load faster.

ActiveStorage uses the image_processing gem under the hood to accomplish this. Here is how you could render downsized version of a user avatar:

<%= image_tag resource.avatar.variant(resize: "100x100!") %>

I’ve found ActiveStorage’s variant mechanism very easy to use, but it does have its downsides. Underlying libraries, such as libvips or minimagick have to be installed and kept up to date. Additionally, transforming images is CPU- and memory-intensive work, and doing this on your app servers may cause issues as your app grows.

Imgproxy is an alternative way to process images. A small service written in Go, it does one thing, and one thing only: processing images. As such, it allows us to offload the job of processing images to specialized machines, alleviating some of the problems described above.

To show you how that works we’ll create a small application that allows users to upload profile images. We’ll then use image_processing and later Imgproxy to serve downsized versions of those same images.

Words are nice and all, but code is pretty cool too, right? You can find the source code for this guide on GitHub if you prefer that.

Setting up the App

The simplest way to get a user model and user profile page is by using Devise.

I set up a demo application called ‘Avatars’ using Schienenzeppelin, my own Rails template that is configured with Devise, Tailwind, and other libraries.

sz avatars

If you are starting with a fresh app, you’ll have to install and configure Devise yourself. I recommend you follow the instructions here.

Once your app is up and running, install ActiveStorage:

rails active_storage:install

Next, update your user model to allow attaching images to it.

has_one_attached :avatar

We’ll now update some views and controllers to enable users to upload profile images. There are several ways to go about this, I prefer ejecting Devises controllers and modifying them as necessary.

rails generate devise:controllers users

We’ll need to update only the registrations_controller.rb.

class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_account_update_params, only: [:update]

  def configure_account_update_params
    devise_parameter_sanitizer.permit(:account_update, keys: %i[name avatar])
  end
end

To tell Devise to use this controller, update routes.rb.

devise_for :users, controllers: { registrations: 'users/registrations' }

We are now ready to update the view where users can update their profile. Open app/views/devise/registrations/edit.html.erb. If that file is not present you have to run rails generate devise:views users first.

The code below allows users to upload a new profile image, displays it if it is present, and shows a fallback icon using inline_svg otherwise.

<div class="input-group">
  <% if resource.avatar.attached? %>
    <%= image_tag url_for(resource.avatar), class: "rounded" %>
  <% else %>
      <%= inline_svg_pack_tag('media/images/user.svg', class: "rounded", size: "5rem * 5rem") %>
    <% end %>

    <%= f.file_field :avatar %>
</div>

With these changes, you should be able to upload user profile images and display them.

Profile

Processing Images

Our current implementation renders exactly the image that the user uploaded. That’s wasteful. What if they upload a 15 megapixel, high-res picture of their face? To serve processed images - variants - we’ll need to install the image_processing gem.

Add this to your Gemfile and run bundle install.

gem 'image_processing'

Now replace url_for(resource.avatar) with resource.avatar.variant to serve a resized image.

<%= image_tag resource.avatar.variant(resize: "100x100!"), class: "rounded" %>

You can find additional info on variants in the official documentation.

Adding Imageproxy

So far so good. Image upload and processing using image_processing works, now let’s use Imgproxy. Add the Imgproxy client, and once again run bundle install.

gem 'imgproxy'

There are several ways to configure the Imgproxy client. I prefer using an initializer, initializers/imgproxy.rb

Imgproxy.configure do |config|
  config.endpoint = 'http://localhost:8080'
  config.key = '696d6770726f7879' # imgproxy
  config.salt = '73616c74' # salt
end

Key and salt are not strictly required but enable URL signing, which is a good security practice. You can find more information about URL signing in the Imgproxy documentation.

To replace image_processing with imgproxy update the view and replace the line where we retrieve the variant with:

<%= image_tag resource.avatar.imgproxy_url(width: 100, height: 100, format: 'jpg'), class: "rounded" %>

In my testing I faced an issue where imgproxy_url failed. If that is the case for you as well, you may need to add Rails.application.routes.default_url_options[:host] = 'localhost:3000' to your development.rb.

Our Rails app is good-to-go. The last thing remaining is to start the Imgproxy service. If you use docker-compose add the following to your compose file and run docker-compose up.

imgproxy:
  container_name: imgproxy
  image: darthsim/imgproxy:latest
  environment:
    - IMGPROXY_KEY=696d6770726f7879 # imgproxy
    - IMGPROXY_SALT=73616c74 # salt
    - IMGPROXY_LOCAL_FILESYSTEM_ROOT=/storage
  network_mode: host

Note that network_mode: host is required, as Imgproxy needs to connect to your Rails application to retrieve original images. For alternative ways to run Imgproxy consult the documentation.

If you reload your profile page, you should see something like this in your Docker logs, which of course means that Imgproxy is doing its job.

imgproxy         | INFO    [2021-03-19T07:57:06Z] Completed in 61.071755ms /../s:100:100/plain/http://localhost:3000/rails/active_storage/blobs/redirect/../[email protected]  request_id=7l4aJfTZ6kdjq0Ul1ITx9 method=GET status=200 image_url="http://localhost:3000/rails/active_storage/blobs/redirect/.../Hans.png" processing_options="Width: 100; Height: 100; Format: jpeg"

Conclusion

Imgproxy can alleviate some of the pains associated with running ActiveStorage as your app grows - even if it requires additional infrastructure. In the context of this post, we didn’t see much of that.

A word of warning: Don’t get all hyped up. Using Imgproxy is probably not worth it unless your app is large enough. But still: It’s good to know what’s out there and what is possible, right?

I hope you learned a thing or two - let me know if you found this useful!

Updated: