When I started this blog, I knew embedding photos and a serverless file upload solution (likely S3) for screenshots and custom photos was a feature I definitely needed. And two months ago at the Boulder Ruby Meetup, Kyle Brackman gave an excellent talk on using S3 in Rails, which piqued my interest and pointed me in the right direction.
In this post, I'll cover:
- Why Serverless, why S3?
- Design
- Implementation
Why Serverless, Why S3?
Serverless means you, the developer, don't have to worry about server management. It's a pay-as-you-go cloud service, so you can focus on the fun stuff, like app building. While my small blog uploads could be hosted server-side, I thought implementing S3 would be a great learning opportunity to gain hands-on experience with an industry-standard tool and avoid server-side rendering for static content.
S3 is surprisingly simple to integrate with Rails, even for small apps. Again, while server-side rendering would work well enough for my low-scale blog, solutions like S3 become essential when scaling to millions of users with big-boy companies like YouTube or Twitter.
🔦 Side note: The growing Anti Cloud movement in late 2024 is fascinating and I understand that there are reasons to not lock into S3. Still, the value of gaining experience with widely used tools like S3 can't be overstated.
Design
The way I designed this blog was without a WYSIWYG editor. I do almost all my writing in Notion and implementing a WYSIWYG editor in Rails didn’t feel as rewarding. I wanted to go deeper, ya know? Also, I did consider ActionText
, but it doesn't appear to support code blocks but rather rich text content (a lighter-weight MD solution) based on the Trix editor. And even though there were some other JavaScript editors that did, they weren’t well maintained. The last thing I wanted to do was get to this point in the build, have some abstracted problem with the editor, check the repo, and see an untouched open PR or Issue about it from a year ago. Plus, Rails has a wonderful Markdown to HTML renderer called Redcarpet that I will be hyping up later in this post!
👨💻 Current Davis: I just googled JS WYSIWYG editors and found one called called Quill that looks pretty awesome. I still like the current design choice, it’s taught me a lot. But perhaps if I migrate the Frontend to a separate JS app, I’ll give Quill a shot!
The final decision for the design was settled, post.content
would be a simple MD string that I would render through a custom Redcarpet/Rouge method. I will talk about wrapping Rouge into Redcarpet in another post and using it for code block syntax highlighting. It's awesome! Below is a snapshot of what the post.content
string looks like.

Back to photo embedding! In Redcarpet, photos can be embedded with standard MD syntax: 
and uploaded through ActiveStorage
to S3! This takes some basic setup and if you’re curious about that, check out this blog post where I walk through the process.
The Problem
For images already hosted somewhere on the web, Redcarpet has got it covered. For the images I want to upload for Posts, I had an interesting problem to solve. The Post create/edit text area is just a plain text area and the Post content is just a large MD string. This means I don’t have the S3 URL until that upload takes place.
So how do I reference a photo not immediately uploaded to S3 without a URL..?

The Solution
As long as I keep file names unique, I can use them as placeholders in my posts like this: ![its-my-photo-1.png]
! But we still need to give S3 time to upload AND somehow replace the placeholder with the URL for that process. So by extending Redcarpet (something they encourage you to do), we can clear this hurdle!
Typically when you haven’t extended Redcarpet, you just call it’s markdown
method in your view and pass it the Markdown content like this: <%= markdown(content) %>
but in order to extract the filename from an otherwise lengthy S3 URL, we can extend the markdown
render method with this custom banger:
app/helpers/application_helper.rb
def markdown(content)
renderer = HTMLWithRouge.new(hard_wrap: true)
options = {
fenced_code_blocks: true,
tables: true,
autolink: true,
strikethrough: true,
footnotes: true,
superscript: true,
underline: true,
highlight: true,
quote: true
}
Redcarpet::Markdown.new(renderer, options).render(content).html_safe
end
def render_post_content(post)
text = post.content.dup
post.images.each do |image|
filename = image.blob.filename.to_s
pattern = /!\\[#{Regexp.escape(filename)}\\]/i
if text.match?(pattern)
replacement = %(
<div class="post-content-image-wrapper">
<img
src="#{url_for(image)}"
alt="#{filename}"
class="post-content-image"
data-action="click->lightbox#open"
data-controller="lightbox"
/>
</div>
).strip
text.gsub!(pattern, replacement)
end
end
markdown(text)
end
We need to replace placeholders in the post.content
with the corresponding S3 URL formatted as an <img>
.
- Firstly, we dupe the actual
post.content
to make sure we don’t mutate the original data. - Then for each associated image, we get the
filename
- Define a Regex pattern to match our placeholder
![filename]
- Sub it for a custom HTML block containing the
<img>
block with the S3 URL - Finally, after all that, we render the enter markdown content string and can call it in the view with:
<%= render_post_content(@post) %>
!
(+) Add Photos
Within the posts form, we can use methods within ActiveStorage
for file uploading. Rails makes this very easy per usual!
app/views/admin/posts/_form.html.erb
<%= form.label :images, "Upload Images", class: "add-post-form__label" %>
<%= form.file_field :images, multiple: true, direct_upload: true, class: "add-post-form__file-upload-input" %>
The most important line here is the form.file_field
which directs uploads to the post.images
attribute. Also, notable:
multiple: true
: Allows the user to select and upload multiple files at once.direct_upload: true
: Integrates withActiveStorage
direct upload feature, enabling files to be uploaded directly to S3 without passing through the Rails server!

Controller logic is relatively indifferent from other creation logic
app/controllers/admin/posts_controller.rb
def create
@post = Post.new(post_params)
@post.user = current_user
if @post.save
redirect_to admin_posts_path, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end
And finally within the model, all we have is one simple line!
app/models/post.rb
has_many_attached :images, dependent: :purge_later
Notably, dependent: :purge_later
is a callback similar to dependent: :destroy
. However, it does photo removal in the background rather than immediately.
(-) Delete Photos
Now when it comes to removing photos, initially I hadn’t considered the behavior of resubmitting a post form for updating when there are photos ALREADY uploaded. It’s a rather large oversight on my part, can you spot the issue??
app/controllers/admin/posts_controller.rb
def update
@post.assign_attributes(post_params) # 👀 Do you see it?!
if @post.save
redirect_to admin_posts_path, notice: "Post updated."
else
render :edit, status: :unprocessable_entity
end
end
Nice, you’re correct! We are assigning ALL attributes including :images
which will overwrite the current images at @post.save
with a blank array effectively deleting any photos we had already uploaded! Oop..
So to fix this, we skip assigning the :images
param directly and then check two things.
app/controllers/admin/posts_controller.rb
def update
@post.assign_attributes(post_params.except(:images))
if post_params[:images].present?
@post.images.attach(post_params[:images])
end
if params[:remove_images].present?
params[:remove_images].each do |blob_id|
attachment = @post.images.attachments.find_by(blob_id: blob_id)
attachment&.purge
end
end
if @post.save
redirect_to admin_posts_path, notice: "Post updated."
else
render :edit, status: :unprocessable_entity
end
end
post_params[:images].present?
: Checks and attaches new:images
.@post.images.attach
is not a clobber action and will append to the existing array. Nice!params[:remove_images].present?
: Checks and removes:images
in the:remove_images
array with the.purge
method. We will get to how this is implemented next!
Let’s get to viewing and removing photos already attached to a post when editing. This is where the params[:remove_images]
array will come from. We add an additional field to the form with a basic conditional to display only if images exist.
app/views/admin/posts/_form.html.erb
<% if @post.images.attached? %>
<div class="add-post-form__field add-post-form__existing-images">
<%= form.label :images, "Existing Images", class: "add-post-form__label" %>
<% @post.images.each do |img| %>
<div class="existing-image-wrapper">
<%= image_tag img, width: 100 %>
<span class="add-post-form__checkbox-label"><%= img.blob.filename.to_s %></span>
<label class="add-post-form__checkbox-item">
<%= check_box_tag "remove_images[]", img.blob.id, false, class: "add-post-form__checkbox" %>
<span class="add-post-form__checkbox-label">Remove?</span>
</label>
</div>
<% end %>
</div>
<% end %>

We iterate through all the @post.images
displaying all attached images at a fixed 100px width with a checkbox to add ‘em to the params[:remove_images]
array and delete them on @post.save
if we want! Oh and of course we also need to remove or update the placeholder in the editor too.
💫 That’s it! ⭐️
Closing Thoughts
Building this blog has been a rewarding learning experience, especially implementing S3 for serverless file uploads. It reinforced why I love Rails so much, if you think it’s possible to build in Rails, it likely is!
I hope this post provided some helpful insights or inspiration. A comment section for feedback is on my to-do list, but until then, thanks for reading, and happy coding/uploading!
