All Google queries for watermarking an image with ActiveStorage currently point to this great, thorough result from 2018. ActiveStorage has changed a lot in the past three years, and change_options is no longer the going syntax as of 2021.

It can be much simpler to generate primitive watermarks with ActiveStorage 6.1. The basic concept remains the same: you pass imagemagick command line options to the variant method of your attachment. Given the wealth of options that imagemagick provides, it's a very flexible way to make a lot of possible things happen to an image.

Given a model called Feature that includes a has_one_attached :screenshot, this incantation will emboss the words "" in the lower-right corner of an image.

linkUsing image_processing default (as of 6.1) processor, imagemagick

To process a watermark variant with the imagemagick processor

feature.screenshot.variant(gravity: "Southeast", pointsize: "30", fill: "#999", draw: "text 0,0 ''").processed.url

It uses two key options: "text" and "gravity" from imagemagick.

So an image like this:

Has this variant generated (squint your eyes and look at the lower right corner):

linkExample of using image_processing's ruby-vips processor

Another watermarking exercise we needed to undertake was to add "" to the bottom of some images that would be dynamically generated by customers. We decided to use ruby-vips for this since the gem is automatically installed with image_processing and their documentation promises substantially faster processing than imagemagick. Inspired by the ruby-vips example provided here, we used this code to go from a watir screenshot to an ActiveStorage image with a watermark:

browser =, options: { args: [ "--headless" ] }, service: { path: })
browser.goto ""
width, height = 750, 550 # Arbitrary amount of the page we'll capture
image = Vips::Image.new_from_buffer(browser.screenshot.png, "").crop(0,  0, width, height)
# DPI controls size of text, 100 equals approximately 20 point font
text = Vips::Image.text("", dpi: 100).gravity("south-east", width, height)
text = (text * 0.3).cast(:uchar) # Make it 30% transparent
text_color_rgb = [255, 255, 255]
overlay = (text.new_from_image(text_color_rgb)).copy(interpretation: :srgb)
overlay = overlay.bandjoin(text)
image = image.composite(overlay, :over)
active_storage_attachment.attach(io:".jpg[Q=90]")), filename: "test-#{ Time.current.to_i }.jpg", content_type: "image/jpg")

If you open test.png after running this, you'll discover a beauty like this:

Note the text in the lower-right corner. 💅

linkTrack variants to avoid reuploading your variant on every process

These options are best combined with the Rails' much appreciated new option to store which variants have been previously generated in the database, and reuse those instead of regenerating (+ reuploading) a new image on every call to the variant method.

# In config/application.rb
# Store in the local DB the URL to an already-generated variant and use that when available
config.active_storage.track_variants = true

linkExtra credit: To avoid ending up with an N+1 when loading variants

Since all this ActiveStorage variant business is relatively new as of mid-2021, work is still ongoing to ensure that variants can be preloaded to avoid an N+1 query situation when showing many images on a page. This PR looks set to offer new methods that can be utilized to eager-load an attachment and its variants. In the meantime, the following patch can be added to config/initializers to allow the possibility of using variant records when they have been preloaded:

require "active_storage/variant_with_record"

# Patch in
module EagerLoadVariant
  def record
    @record ||= if blob.variant_records.loaded?
      blob.variant_records.detect { |v| v.variation_digest == variation.digest }
      blob.variant_records.find_by(variation_digest: variation.digest)

raise("Has the PR for this been merged yet?") unless Rails.version =~ /6\.1/

Of course, if you are looking to show the public version of these watermarked images, the path to ensuring the resource is eager-loaded won't be mistaken for simple:

Feature.includes(screenshot_attachment: { blob: { variant_records: { image_attachment: :blob }}}

The outer screenshot_attachment is the name of the attachment added to Feature. Variant records are recorded by blob, so we must look up both of those. The actual key used by the variant image is nestled even further down, in the variant_record's own has_one_attached :image association, which holds yet another blob 😅. It looks like the incoming PR doesn't simplify this possible variant lookup, which makes me wonder if there is supposed to be an easier way to load the public key used by rails_public_blob_url. Feel free to drop a line in the comments if you discover a better way to ensure no N+1 looking up the public URL of a generated variant.