Upload images to Amazon S3 with Paperclip on Rails

Sometimes uploading files to your server's local filesystem is not enough due to some constraints like a limited disk space and potential security issues caused by allowing users to upload files to your server. Using third party services like Amazon S3 for file storage is a good way to solve these issues. I decided to use the Paperclip gem to implement this functionality on my Rails app.

Setup Amazon S3

This article does not cover setting up an Amazon S3 bucket. This article assumes that you have already setup your Amazon S3 bucket and familiar with your AWS credentials. You should know your Amazon S3 bucket's name, access id, secret access key and region.


Demo app

I'll demonstrate how to upload files to S3 with Paperclip on Rails by building a simple demo app. You can download the source code here.

Create a new rails project by executing the command below.

rails new paperclip-s3-example


Setup Figaro

Use the gem Figaro to make it easy to securely configure the rails demo application.

Add this line to your Gemfile.

gem 'figaro'

Install figaro by executing the commands below.

bundle install
bundle exec figaro install

This will generate an application.yml file at the config directory. The file's content should look like the code below. Replace the value with your Amazon S3 bucket's credentials.

config/application.yml

S3_BUCKET_NAME: "mybucketname"
AWS_ACCESS_KEY_ID: "AKI84JDHFYRKW80Q43RQ"
AWS_SECRET_ACCESS_KEY: "HJD348asd3dgdj3ysdjshHDSJ39DSH393D"
AWS_REGION: "ap-southeast-1"

This file contains the credentials for your S3 bucket and shouldn't be added to your git repository, fortunately figaro adds this file to your .gitignore file upon installation. A good practice for using figaro is adding an application.yml.template file to your repository's config directory so that other users can just copy its contents when setting up their application.yml on their new development environment.

config/application.yml.template

S3_BUCKET_NAME: ""
AWS_ACCESS_KEY_ID: ""
AWS_SECRET_ACCESS_KEY: ""
AWS_REGION: ""


Configure Paperclip to use Amazon S3

Use the official AWS and Paperclip gems.

Add these lines to the Gemfile.

gem 'paperclip', '~> 5.0.0'
gem 'aws-sdk', '~> 2'

Install these gems by executing the code below.

bundle install

Modify and add the code below to your config/environments/development.rb and config/environments/production.rb.

config.paperclip_defaults = {
    storage: :s3,
    s3_credentials: {
      bucket: ENV.fetch('S3_BUCKET_NAME'),
      access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'),
      secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY'),
      s3_region: ENV.fetch('AWS_REGION'),
    }
}


Product model

Generate a Product model with an attribute "name" by executing the command below.

rails g model product name:string

Add an image attachment attribute by execute the command below.

rails g paperclip product image

This will generate a migration like the code below.

db/migrate/20161203110020_add_attachment_image_to_products.rb

class AddAttachmentImageToProducts < ActiveRecord::Migration
  def self.up
    change_table :products do |t|
      t.attachment :image
    end
  end

  def self.down
    remove_attachment :products, :image
  end
end

Execute the migration by running the code below.

rake db:migrate

Modify the Product model to add attachment functionality. Use the Paperclip helper method has_attached_file and a symbol with the desired name of the attachment.

app/models/product.rb
class Product < ActiveRecord::Base
  has_attached_file :image, styles: {
    thumb: '100x100>',
    preview: '300x225#',
    large: '600x600>'
  }
  validates_attachment_content_type :image, :content_type => /\Aimage\/.*\Z/
end

Products controller

Generate a controller for products.

rails g controller products

Modify the ProductsController to look like the code below.

app/controllers/products_controller.rb
class ProductsController < ApplicationController
  before_action :set_product, only: [:show, :edit, :update, :destroy]

  def index
    @products = Product.all
  end

  def show
  end

  def new
    @product = Product.new
  end

  def edit
  end

  def create
    @product = Product.new(product_params)
    if @product.save
      redirect_to @product, notice: 'Product was successfully created.'
    else
      render :new
    end
  end

  def update
    if @product.update(product_params)
      redirect_to @product, notice: 'Product was successfully updated.'
    else
      render :edit
    end
  end

  def destroy
    @product.destroy
    redirect_to products_url, notice: 'Product was successfully destroyed.'
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_product
      @product = Product.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def product_params
      params.require(:product).permit(:name, :image)
    end
end

Diplaying the image on your views

Create an index file for our homepage. Its content should look like the code below and take note of the product.image.url(:thumb). It is the reference to the resized version of the image.

app/views/products/index.html.erb
<p id="notice"><%= notice %></p>
<h1>Listing Products</h1>

<table>
  <thead>
    <tr>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @products.each do |product| %>
      <tr>
        <td><%= product.name %></td>
        <td><%=  image_tag product.image.url(:thumb) %></td>
        <td><%= link_to 'Show', product %></td>
        <td><%= link_to 'Edit', edit_product_path(product) %></td>
        <td><%= link_to 'Destroy', product, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Product', new_product_path %>

Create a partial form for our new and edit actions. The file_field will be used for selecting a file to upload. Take note of :html => {:multipart => true}, this is necessary to upload the image in multiple chunks.

app/views/products/_form.html.erb
<%= form_for(@product,:html => {:multipart => true}) do |f| %>
  <% if @product.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@product.errors.count, "error") %> prohibited this product from being saved:</h2>

      <ul>
      <% @product.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <p>
    <%= f.label :name %><br/>
    <%= f.text_field :name %>
  </p>
  <p>
    <%= f.file_field :image %>
  </p>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

Create our view for our show action. Notice that I use photo.image.url instead of photo.image.url(:thumb) like on our index page. This shows the original size of the uploaded.

app/views/products/show.html.erb
<p id="notice"><%= notice %></p>
<td><%= @product.name %></td>
<td><%=  image_tag @product.image.url %></td>
<%= link_to 'Edit', edit_product_path(@product) %> |
<%= link_to 'Back', products_path %>
app/views/photos/new.html.erb
<h1>New Product</h1>
<%= render 'form' %>
<%= link_to 'Back', products_path %>
app/views/photos/edit.html.erb
<h1>Editing Product</h1>
<%= render 'form' %>
<%= link_to 'Show', @product %> |
<%= link_to 'Back', products_path %>

Make sure we have configured our routes properly.

config/routes.rb
Rails.application.routes.draw do
  resources :products
end

The app is finished. Execute the code below.

rails s

Go to http://localhost:3000/products and see the rails app work. Happy coding!