Deploying a 12-Factor App with Capistrano
Deploying Heroku-style 12 factor applications outside of Heroku has been an issue for lots of people. I've written several different systems that scratch this particular itch, and in this post I'll be describing a version that deploys one particular app using a Heroku-style buildpack, Foreman, and launchd on Mac OS X via Capistrano.
I've been deploying a customized version of ledger-web on my Mac mini using dokuen for almost six months. A few nights ago, however, I tried to deploy a version and discovered my Dokuen install was completely busted. Instead of doing the correct thing and fixing my Dokuen install I wrote a completely new deployment system using Capistrano.
Essentially, this deployment uses the standard :checkout
deploy strategy with hooks that clone and run a buildpack, build a .env
file, and run Foreman to create launch scripts.
Dependencies
This config depends on the following on the deployment target:
- Mac OS X
- Ruby 1.9.3 (installed from homebrew)
- the Foreman gem
Configuration
There's a bunch of config that happens at the top of file. First, the standard config settings:
set :application, "ledger"
set :repository, "git@git.mydomain.com:peter/ledger-app.git"
set :deploy_to, "/Users/peter/apps/ledger"
set :scm, :git
role :web, "lionel.local"
role :db, "lionel.local", :primary => true
set :user, "peter"
These define my app, the repository, and a few other standard things. It also sets my Mac mini, named lionel
to be the deployment target.
default_run_options[:pty] = true
default_run_options[:shell] = '/bin/bash'
:pty
and :shell
are required by several scripts that run later.
Next are settings that are used by my custom hooks:
set :base_port, 6500
set :buildpack_url, "https://github.com/peterkeen/heroku-buildpack-ruby"
set :buildpack_hash, Digest::SHA1.hexdigest(buildpack_url)
set :buildpack_path, "#{shared_path}/buildpack-#{buildpack_hash}"
set :concurrency, "web=1"
set :launchd_conf_path, "/Users/peter/Library/LaunchAgents"
These set up my buildpack, more deployment paths, etc. Of particular note are :concurrency
, which controls what Foreman exports, and :base_port
which is what Foreman will set as the first port for the web
procfile entries.
set :deploy_env, {
'DATABASE_URL' => 'postgres://user@dbhost/database',
'LEDGER_FILE' => '/path/to/ledger.txt',
'LEDGER_USERNAME' => 'username',
'LEDGER_PASSWORD' => 'password',
'LANG' => 'en_US.UTF-8',
'PATH' => 'bin:vendor/bundle/ruby/1.9.1/bin:/usr/local/bin:/usr/bin:/bin',
'GEM_PATH' => 'vendor/bundle/ruby/1.9.1',
'RACK_ENV' => 'production',
}
:deploy_env
sets up a hash of environment variables that will be exported later. I don't run bin/release
because I found that it will always return the same set of environment variables and I don't care about the default procfile entries or addons. If you do, feel free to parse out the results of bin/release
, which returns a YAML hash.
Hooks
So now that the setup is done, deploy happens as normal with just a few hooks. First, a before deploy
hook that sets up the buildpack and build cache:
before "deploy" do
run("[[ ! -e #{buildpack_path} ]] && git clone #{buildpack_url} #{buildpack_path}; exit 0")
run("cd #{buildpack_path} && git fetch origin && git reset --hard origin/master")
run("mkdir -p #{shared_path}/build_cache")
end
Next, after the normal deploy happens but before the symlink is switched, we hook in and run the buildpack:
before "deploy:finalize_update" do
run("cd #{buildpack_path} && bin/compile #{release_path} #{shared_path}/build_cache")
env_lines = []
deploy_env.each do |k,v|
env_lines << "#{k}=#{v}"
end
env_contents = env_lines.join("\n") + "\n"
put(env_contents, "#{release_path}/.env")
end
This hook also writes out the environment variables we defined earlier in a way that Foreman can pick up.
Finally, we redefine the deploy:restart
task to run Foreman and restart the generated LaunchAgent:
namespace :deploy do
task :restart do
sudo "launchctl unload -wF #{launchd_conf_path}/ledger-web-1.plist; true"
sudo "foreman export launchd #{launchd_conf_path} -d #{release_path} -l /var/log/#{application} -a #{application} -u #{user} -p #{base_port} -c #{concurrency}"
sudo "launchctl load -wF #{launchd_conf_path}/ledger-web-1.plist; true"
end
end
This hardcodes the plist
name that Foreman generates because it was late and I was tired. Also, sudo
didn't like my initial stab at a for
loop and I cut my losses. It wouldn't be too hard to write out a tiny script and execute it, though.
Nginx
Dokuen was also managing my nginx configuration for each app. I added a simple proxy definition for ledger
instead:
server {
server_name ledger.mydomain.com;
listen 443;
ssl on;
location / {
proxy_pass http://localhost:6500/;
}
}
Result
At this point I think this is a better model than Dokuen for deploying 12 factor applications on my own hardware. There are no extra daemons to keep running, there's no extra software on the server (except Foreman), there's no weird sudo definitions.
Deploying on a cluster is a slightly different story. I would probably change this do build a tarball on an Anvil server and then distribute the tarball out to the rest of the machines instead of building on every machine, among other changes.