Capistrano: Managing an uploads folder

Quite often you might want to integrate upload capabilities in your Rails application. For example, you want to provide users the ability to configure their avatar or your e-commerce needs to display an image in the product page.

Creating an upload system in Rails is a piece of cake. There are tons of plugins out of there that you can install and configure in a couple of click. Here's a couple of examples:

  1. attachment_fu
  2. paperclip

Problems might arise at deployment time if you are managing your Rails application with Capistrano. By default Capistrano doesn't support an additional upload/images folder unless it is under version control and each time you'll deploy your Rails application the path stored for the uploaded files will be invalidated.

The following article shows you how to properly configure Capistrano in order to preserve your uploads folder across multiple releases.

The Scenario

Let me show you an example. You've just deployed your Rails project. The remote folder tree should look like the following one:

/deploy/folder
|
+ current (s => /deploy/folder/releases/200802251356)
|
+ relases
  |
  + 200802251356

The uploaded files are probably stored somewhere within the public folder in /deploy/folder/releases/200802251356/public. Let's assume the folder is /deploy/folder/releases/200802251356/public/uploads.

You upload two files from your administration panel. The first one called first.png and the second one called second.gif. Now your folder tree will look as follows.

/deploy/folder
|
+ current (s => /deploy/folder/releases/200802251356)
|
+ relases/
  |
  + 200802251356/
    |
    + public/
      |
      + uploads/
        |
        + first.png
        + second.gif

You must bear in mind that we are now talking about server path. From the public side, your user doesn't know the full path where the files are stored. The application would probably reference the files with an absolute or relative path starting from your website domain.

In our example, the images are available at /uploads/first.png and /uploads/second.gif or http://example.com/uploads/first.png and http://example.com/uploads/second.gif if the application relies on the full url.

Where are my files?

Time passes and a new release is ready for deployment. Now you've got a problem: as soon as the new release is deployed, Capistrano updates the target of the current symlink to point to the new version.

/deploy/folder
|
+ current (s => /deploy/folder/releases/200802261100)
|
+ relases/
  |
  + 200802251356/
  | |
  | + public/
  |   |
  |   + uploads/
  |     |
  |     + first.png
  |     + second.png
  |
  + 200802261100/
  | |
  | + public/
  |   |
  |   + uploads/

Hey, wait a minute… where are my files? Because the files uploaded by the users are not under version control, as soon as the new version is checked out from the repository and the current symlink updated, all your files will become unavailable.

The URL http://example.com/uploads/second.gif now points to /deploy/folder/releases/200802261100/public/uploads/second.gif that doesn't exist.

You must tell Capistrano to move or copy the reference to the uploads folder each time a new version is deployed.

The solution

First of all, let me show you the solution. I'll explain that in more detail later.

# ==============================
# Uploads
# ==============================

namespace :uploads do

  desc <<-EOD
    Creates the upload folders unless they exist
    and sets the proper upload permissions.
  EOD
  task :setup, :except => { :no_release => true } do
    dirs = uploads_dirs.map { |d| File.join(shared_path, d) }
    run "#{try_sudo} mkdir -p #{dirs.join(' ')} && #{try_sudo} chmod g+w #{dirs.join(' ')}"
  end

  desc <<-EOD
    [internal] Creates the symlink to uploads shared folder
    for the most recently deployed version.
  EOD
  task :symlink, :except => { :no_release => true } do
    run "rm -rf #{release_path}/public/uploads"
    run "ln -nfs #{shared_path}/uploads #{release_path}/public/uploads"
  end

  desc <<-EOD
    [internal] Computes uploads directory paths
    and registers them in Capistrano environment.
  EOD
  task :register_dirs do
    set :uploads_dirs,    %w(uploads uploads/partners)
    set :shared_children, fetch(:shared_children) + fetch(:uploads_dirs)
  end

  after       "deploy:finalize_update", "uploads:symlink"
  on :start,  "uploads:register_dirs"

end

In the course of time I realized the best way to accomplish this task is to use symlinks and callbacks.

On setup Capistrano creates a special folder called shared, shared across all deployed revisions. This folder contains, for example, the Rails logs and temporary files. All shared folders are accessible in the Capistrano configuration with the :shared_children variable.

The recipe tells Capistrano to create an additional folder on setup called uploads. This folder is appended to the default :shared_children array to take advantage of the original Capistrano deploy:setup task.

Then, a new task called uploads:symlink is created and configured as an after callback for the deploy:finalize_update Capistrano task. Now, each time a new version is deployed, Capistrano creates a symlink called /public/uploads in the fresh revision pointing to the shared/uploads folder. Your files will no longer be lost across multiple deployments!

Now you just need to configure your Rails application to store all your uploaded files within the /public/uploads folder, perhaps nicely organized in subfolders depending on content type.

Multistage recipe

The package capistrano-ext contains an excellent recipe to add support for multiple staging when deploying a Rails environment. I often use it to differentiate between the staging and production environment.

If you are using the multistage recipe and multiple stages share the same server, you must store files in different folders to prevent stage conflicts. The following recipe is a modified version of the original one with support for multiple environments on the same server.

# ==============================
# Uploads
# ==============================

namespace :uploads do

  desc <<-EOD
    Creates the upload folders unless they exist
    and sets the proper upload permissions.
  EOD
  task :setup, :except => { :no_release => true } do
    dirs = uploads_dirs.map { |d| File.join(shared_path, d) }
    run "#{try_sudo} mkdir -p #{dirs.join(' ')} && #{try_sudo} chmod g+w #{dirs.join(' ')}"
  end

  desc <<-EOD
    [internal] Creates the symlink to uploads shared folder
    for the most recently deployed version.
  EOD
  task :symlink, :except => { :no_release => true } do
    run "rm -rf #{release_path}/public/uploads"
    run "ln -nfs #{shared_path}/#{stage}/uploads #{release_path}/public/uploads"
  end

  desc <<-EOD
    [internal] Computes uploads directory paths
    and registers them in Capistrano environment.

    Note. I can't set value for directories directly in the code because
    I don't know in advance selected stage.
  EOD
  task :register_dirs do
    set :uploads_dirs,    %w(uploads uploads/partners).map { |d| "#{stage}/#{d}" }
    set :shared_children, fetch(:shared_children) + fetch(:uploads_dirs)
  end

  after       "deploy:finalize_update", "uploads:symlink"
  on :start,  "uploads:register_dirs",  :except => stages + ['multistage:prepare']

end