├── Capfile ├── README.md ├── WordPress-Dropins ├── wp-stack-cdn.php ├── wp-stack-manual-db-upgrades.php ├── wp-stack-ms-uploads.php └── wp-stack-staging.php ├── config ├── .gitignore ├── SAMPLE.config.rb ├── SAMPLE.production.rb ├── SAMPLE.staging.rb └── deploy │ ├── production.rb │ └── staging.rb └── lib ├── deploy-after.rb ├── deploy.rb ├── misc.rb └── tasks.rb /Capfile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'railsless-deploy' 3 | load 'lib/misc' 4 | 5 | # Multistage 6 | set :stages, ['production', 'staging'] 7 | set :default_stage, 'production' 8 | require 'capistrano/ext/multistage' 9 | 10 | load 'lib/tasks' 11 | load 'lib/deploy' # Loads config/config.rb after 12 | load 'lib/deploy-after' 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WP Stack 2 | A toolkit for creating professional [WordPress][wp] deployments. 3 | 4 | Commissioned by [Knewton](http://www.knewton.com/). 5 | 6 | [wp]: http://wordpress.org/ 7 | 8 | ## Why 9 | WordPress runs professional sites. You should have a professional deployment to go along with it. You should be using: 10 | 11 | * Version control (like Git) 12 | * A code deployment system (like Capistrano) 13 | * A staging environment to test changes before they go live 14 | * CDN for static assets 15 | 16 | Additionally, you should be able to easily scale out to multiple web servers, if needed. 17 | 18 | WP Stack is a toolkit that helps you do all that. 19 | 20 | ## WordPress Must-use Plugins 21 | 22 | "Must-use" plugins aka `mu-plugins` are WordPress plugins that are dropped into the `{WordPress content dir}/mu-plugins/` directory. They are autoloaded — no need to activate them. WP Stack comes with a number of these plugins for your use: 23 | 24 | ### CDN 25 | 26 | `wp-stack-cdn.php` 27 | 28 | This is a very simple CDN plugin. Simply configure the constant `WP_STACK_CDN_DOMAIN` in your `wp-config.php` or hook in and override the `wp_stack_cdn_domain` option. Provide a domain name only, like `static.example.com`. The plugin will look for static file URLs on your domain and repoint them to the CDN domain. 29 | 30 | ### Multisite Uploads 31 | 32 | `wp-stack-ms-uploads.php` 33 | 34 | The way WordPress Multisite serves uploads is not ideal. It streams them through a PHP file. Professional sites should not do this. This plugin allows one nginx rewrite rule to handle all uploads, eliminating the need for PHP streaming. It uses the following URL scheme for uploads: `{scheme}://{domain}/wp-files/{blog_id}/`. By inserting the `$blog_id`, one rewrite rule can make sure file requests go to the correct blog. 35 | 36 | **Note:** You will need to implement this Nginx rewrite rule for this to work: 37 | 38 | `rewrite ^/wp-files/([0-9]+)/(.*)$ /wp-content/blogs.dir/$1/files/$2;` 39 | 40 | ### Manual DB Upgrades 41 | 42 | Normally, WordPress redirects `/wp-admin/` requests to the WordPress database upgrade screen. On large sites, or sites with a lot of active authors, this may not be desired. This drop-in prevents the automatic redirect and instead lets you manually go to `/wp-admin/upgrade.php` to upgrade a site. 43 | 44 | ## Capistrano 45 | 46 | Capistrano is a code deployment tool. When you have code that is ready to go "live", this is what does it. 47 | 48 | ### Setup 49 | 50 | 1. Create a `deploy` user on your system (Ubuntu: `addgroup deploy; adduser --system --shell /bin/bash --ingroup deploy --disabled-password --home /home/deploy deploy 51 | `). 52 | 2. Create an SSH key for `deploy`, make sure it can SSH to all of your web servers, and make sure it can pull down your site repo code. 53 | * Switch to the deploy user (`su deploy`). 54 | * `ssh-keygen` 55 | * `cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys` 56 | * Add the contents of `~/.ssh/id_rsa.pub` to `~/.ssh/authorized_keys` on every server you're deploying to. 57 | 3. [Install RubyGems][rubygems]. 58 | 4. Install Capistrano and friends: `sudo gem install capistrano capistrano-ext railsless-deploy` 59 | 5. Switch to the deploy user (`su deploy`) and check out WP Stack somewhere on your server: `git clone git@github.com:markjaquith/WP-Stack.git ~/deploy` 60 | 6. Customize and rename `config/SAMPLE.{config|production|staging}.rb` 61 | 7. Make sure your `:deploy_to` path exists and is owned by the deploy user: `chown -R deploy:deploy /path/to/your/deployment` 62 | 8. Run `cap deploy:setup` (from your WP Stack directory) to setup the initial `shared` and `releases` directories. 63 | 64 | [rubygems]: http://rubygems.org/pages/download 65 | 66 | ### Deploying 67 | 68 | 1. Switch to the deploy user: `su deploy` 69 | 2. `cd` to the WP Stack directory. 70 | 3. Run `cap production deploy` (to deploy to staging, use `cap staging deploy`) 71 | 72 | ### Rolling Back 73 | 74 | 1. Switch to the deploy user: `su deploy` 75 | 2. `cd` to the WP Stack directory. 76 | 3. Run `cap deploy:rollback` 77 | 78 | ### About Stages 79 | 80 | There are two "stages": production and staging. These can be completely different servers, or different paths on the same set of servers. 81 | 82 | To sync from production to staging (DB and files), run `cap staging db:sync`. 83 | 84 | ## Assumptions made about WordPress 85 | 86 | If you're not using [WordPress Skeleton](https://github.com/markjaquith/WordPress-Skeleton), you should be aware of these assumptions: 87 | 88 | 1. Your `wp-config.php` file exists in your web root. So put it there. 89 | 2. WP Stack replaces the following "stubs": 90 | * `%%DB_NAME%%` — Database name. 91 | * `%%DB_HOST%%` — Database host. 92 | * `%%DB_USER%%` — Database username. 93 | * `%%DB_PASSWORD%%` — Database password. 94 | * `%%WP_STAGE%%` – will be `production` or `staging` after deploy. 95 | 3. WP Stack uses the constants `WP_STAGE` (which should be set to `'%%WP_STAGE%%'`) and `STAGING_DOMAIN`, which should be set to the domain you want to use for staging (something like `staging.example.com`). -------------------------------------------------------------------------------- /WordPress-Dropins/wp-stack-cdn.php: -------------------------------------------------------------------------------- 1 | sanitize_method($h);$b=func_get_args();unset($b[0]);foreach((array)$b as $a){if(is_int($a))$p=$a;else $m=$a;}return add_action($h,array($this,$m),$p,999);}private function sanitize_method($m){return str_replace(array('.','-'),array('_DOT_','_DASH_'),$m);}}} 11 | 12 | // The plugin 13 | class WP_Stack_CDN_Plugin extends WP_Stack_Plugin { 14 | public static $instance; 15 | public $site_domain; 16 | public $cdn_domain; 17 | public $upload_dir; 18 | public $uploads_only; 19 | public $extensions; 20 | 21 | public function __construct() { 22 | self::$instance = $this; 23 | $this->hook( 'plugins_loaded' ); 24 | } 25 | 26 | public function plugins_loaded() { 27 | $domain_set_up = get_option( 'wp_stack_cdn_domain' ) || ( defined( 'WP_STACK_CDN_DOMAIN' ) && WP_STACK_CDN_DOMAIN ); 28 | $production = defined( 'WP_STAGE' ) && WP_STAGE === 'production'; 29 | $staging = defined( 'WP_STAGE' ) && WP_STAGE === 'staging'; 30 | $uploads_only = defined( 'WP_STACK_CDN_UPLOADS_ONLY' ) && WP_STACK_CDN_UPLOADS_ONLY; 31 | if ( $domain_set_up && !$staging && ( $production || $uploads_only ) ) 32 | $this->hook( 'init' ); 33 | } 34 | 35 | public function init() { 36 | $this->uploads_only = apply_filters( 'wp_stack_cdn_uploads_only', defined( 'WP_STACK_CDN_UPLOADS_ONLY' ) ? WP_STACK_CDN_UPLOADS_ONLY : false ); 37 | $this->extensions = apply_filters( 'wp_stack_cdn_extensions', array( 'jpe?g', 'gif', 'png', 'css', 'bmp', 'js', 'ico' ) ); 38 | if ( !is_admin() ) { 39 | $this->hook( 'template_redirect' ); 40 | if ( $this->uploads_only ) 41 | $this->hook( 'wp_stack_cdn_content', 'filter_uploads_only' ); 42 | else 43 | $this->hook( 'wp_stack_cdn_content', 'filter' ); 44 | $this->hook( 'wp_calculate_image_srcset', 'srcset' ); 45 | $this->site_domain = parse_url( get_bloginfo( 'url' ), PHP_URL_HOST ); 46 | $this->cdn_domain = defined( 'WP_STACK_CDN_DOMAIN' ) ? WP_STACK_CDN_DOMAIN : get_option( 'wp_stack_cdn_domain' ); 47 | } 48 | } 49 | 50 | public function filter_uploads_only( $content ) { 51 | $upload_dir = wp_upload_dir(); 52 | $upload_dir = $upload_dir['baseurl']; 53 | $domain = preg_quote( parse_url( $upload_dir, PHP_URL_HOST ), '#' ); 54 | $path = parse_url( $upload_dir, PHP_URL_PATH ); 55 | $preg_path = preg_quote( $path, '#' ); 56 | 57 | // Targeted replace just on uploads URLs 58 | return preg_replace( "#=([\"'])(https?://{$domain})?$preg_path/((?:(?!\\1]).)+)\.(" . implode( '|', $this->extensions ) . ")(\?((?:(?!\\1).)+))?\\1#", '=$1//' . $this->cdn_domain . $path . '/$3.$4$5$1', $content ); 59 | } 60 | 61 | public function filter( $content ) { 62 | return preg_replace( "#=([\"'])(https?://{$this->site_domain})?/([^/](?:(?!\\1).)+)\.(" . implode( '|', $this->extensions ) . ")(\?((?:(?!\\1).)+))?\\1#", '=$1//' . $this->cdn_domain . '/$3.$4$5$1', $content ); 63 | } 64 | 65 | public function srcset( $sizes) { 66 | return array_map( array( $this, 'replace_subkey_url' ), $sizes ); 67 | } 68 | 69 | public function replace_subkey_url( $src ) { 70 | $src['url'] = str_replace( '//' . $this->site_domain . '/', '//' . $this->cdn_domain . '/', $src['url'] ); 71 | return $src; 72 | } 73 | 74 | public function template_redirect() { 75 | ob_start( array( $this, 'ob' ) ); 76 | } 77 | 78 | public function ob( $contents ) { 79 | return apply_filters( 'wp_stack_cdn_content', $contents, $this ); 80 | } 81 | } 82 | 83 | new WP_Stack_CDN_Plugin; -------------------------------------------------------------------------------- /WordPress-Dropins/wp-stack-manual-db-upgrades.php: -------------------------------------------------------------------------------- 1 | sanitize_method($h);$b=func_get_args();unset($b[0]);foreach((array)$b as $a){if(is_int($a))$p=$a;else $m=$a;}return add_action($h,array($this,$m),$p,999);}private function sanitize_method($m){return str_replace(array('.','-'),array('_DOT_','_DASH_'),$m);}}} 11 | 12 | // The plugin 13 | class WP_Stack_Manual_DB_Upgrades_Plugin extends WP_Stack_Plugin { 14 | public static $instance; 15 | 16 | public function __construct() { 17 | self::$instance = $this; 18 | $this->hook( 'plugins_loaded' ); 19 | } 20 | 21 | public function plugins_loaded() { 22 | $this->hook( 'option_db_version' ); 23 | } 24 | 25 | public function option_db_version( $version ) { 26 | if ( strpos( $_SERVER['REQUEST_URI'], '/wp-admin/upgrade.php' ) === false ) 27 | return $GLOBALS['wp_db_version']; 28 | else 29 | return $version; 30 | } 31 | 32 | } 33 | 34 | new WP_Stack_Manual_DB_Upgrades_Plugin; 35 | -------------------------------------------------------------------------------- /WordPress-Dropins/wp-stack-ms-uploads.php: -------------------------------------------------------------------------------- 1 | sanitize_method($h);$b=func_get_args();unset($b[0]);foreach((array)$b as $a){if(is_int($a))$p=$a;else $m=$a;}return add_action($h,array($this,$m),$p,999);}private function sanitize_method($m){return str_replace(array('.','-'),array('_DOT_','_DASH_'),$m);}}} 11 | 12 | // The plugin 13 | class WP_Stack_MS_Uploads_Plugin extends WP_Stack_Plugin { 14 | public static $instance; 15 | 16 | public function __construct() { 17 | self::$instance = $this; 18 | if ( is_multisite() ) 19 | $this->hook( 'init' ); 20 | } 21 | 22 | public function init() { 23 | global $blog_id; 24 | if ( $blog_id != 1 ) { 25 | $this->hook( 'option_fileupload_url' ); 26 | $this->hook( 'upload_dir' ); 27 | } 28 | } 29 | 30 | public function upload_dir( $upload ) { 31 | /* 32 | array( 33 | 'subdir' => '/2012/07', 34 | 'basedir' => '/Users/mark/Sites/wp.git/wp-content/uploads', 35 | 'path' => '/Users/mark/Sites/wp.git/wp-content/uploads/2012/07', 36 | 'baseurl' => 'http://wp.git/wp-files/1', 37 | 'url' => 'http://wp.git/wp-content/uploads/2012/07', 38 | 'error' => false 39 | ) 40 | */ 41 | global $blog_id; 42 | $parsed = parse_url( $upload['baseurl'] ); 43 | $upload['baseurl'] = $parsed['scheme'] . '://' . $parsed['host'] . '/wp-files/' . $blog_id; 44 | $upload['url'] = $upload['baseurl'] . $upload['subdir']; 45 | return $upload; 46 | } 47 | 48 | // Does core even use this anymore? 49 | public function option_fileupload_url( $url ) { 50 | global $blog_id; 51 | $parsed = parse_url( $url ); 52 | $url = $parsed['scheme'] . '://' . $parsed['host'] . '/wp-files/' . $blog_id . '/'; 53 | return $url; 54 | } 55 | } 56 | 57 | new WP_Stack_MS_Uploads_Plugin; 58 | -------------------------------------------------------------------------------- /WordPress-Dropins/wp-stack-staging.php: -------------------------------------------------------------------------------- 1 | sanitize_method($h);$b=func_get_args();unset($b[0]);foreach((array)$b as $a){if(is_int($a))$p=$a;else $m=$a;}return add_action($h,array($this,$m),$p,999);}private function sanitize_method($m){return str_replace(array('.','-'),array('_DOT_','_DASH_'),$m);}}} 11 | 12 | // The plugin 13 | class WP_Stack_Staging_Plugin extends WP_Stack_Plugin { 14 | public static $instance; 15 | 16 | public function __construct() { 17 | self::$instance = $this; 18 | if ( !defined( 'WP_STAGE' ) || WP_STAGE !== 'staging' || !defined( 'STAGING_DOMAIN' ) ) 19 | return; 20 | $this->hook( 'option_home', 'replace_domain' ); 21 | $this->hook( 'option_siteurl', 'replace_domain' ); 22 | } 23 | 24 | public function replace_domain ( $url ) { 25 | $current_domain = parse_url( $url, PHP_URL_HOST ); 26 | $url = str_replace( '//' . $current_domain, '//' . STAGING_DOMAIN, $url ); 27 | return $url; 28 | } 29 | } 30 | 31 | new WP_Stack_Staging_Plugin; 32 | 33 | -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | /config.rb 2 | /production.rb 3 | /staging.rb 4 | -------------------------------------------------------------------------------- /config/SAMPLE.config.rb: -------------------------------------------------------------------------------- 1 | # Customize this file, and then rename it to config.rb 2 | 3 | set :application, "WP Stack Site" 4 | set :repository, "set your git repository location here" 5 | set :scm, :git 6 | # Or: `accurev`, `bzr`, `cvs`, `darcs`, `git`, `mercurial`, `perforce`, `subversion` or `none` 7 | 8 | # Using Git Submodules? 9 | set :git_enable_submodules, 1 10 | 11 | # This should be the same as :deploy_to in production.rb 12 | set :production_deploy_to, '/srv/www/example.com' 13 | 14 | # The domain name used for your staging environment 15 | set :staging_domain, 'staging.example.com' 16 | 17 | # Database 18 | # Set the values for host, user, pass, and name for both production and staging. 19 | set :wpdb do 20 | { 21 | :production => { 22 | :host => 'PRODUCTION DB HOST', 23 | :user => 'PRODUCTION DB USER', 24 | :password => 'PRODUCTION DB PASS', 25 | :name => 'PRODUCTION DB NAME', 26 | }, 27 | :staging => { 28 | :host => 'STAGING DB HOST', 29 | :user => 'STAGING DB USER', 30 | :password => 'STAGING DB PASS', 31 | :name => 'STAGING DB NAME', 32 | } 33 | } 34 | end 35 | 36 | # You're not done! You must also configure production.rb and staging.rb 37 | -------------------------------------------------------------------------------- /config/SAMPLE.production.rb: -------------------------------------------------------------------------------- 1 | # This file is only loaded for the production environment 2 | # Customize it and rename it as production.rb 3 | 4 | # Where should the site deploy to? 5 | set :deploy_to, "/srv/www/example.com" 6 | 7 | # Now configure the servers for this environment 8 | 9 | # OPTION 1 10 | 11 | # role :web, "your web server IP address or hostname here" 12 | # role :web, "second web server here" 13 | # role :web, "third web server here, etc" 14 | 15 | # role :memcached, "your memcached server IP address or hostname here" 16 | # role :memcached, "second memcached server here, etc" 17 | 18 | # OPTION 2 19 | 20 | # If your web servers are the same as your memcached servers, 21 | # comment out all the "role" lines and use "server" lines: 22 | 23 | # server "your web/memcached server IP address or hostname here", :web, :memcached 24 | # server "second web/memcached server here", :web, :memcached 25 | -------------------------------------------------------------------------------- /config/SAMPLE.staging.rb: -------------------------------------------------------------------------------- 1 | # This file is only loaded for the staging environment 2 | # Customize it and rename it as staging.rb 3 | 4 | # Where should the site deploy to? 5 | set :deploy_to, "/srv/www/example.com" 6 | 7 | # Now configure the servers for this environment 8 | 9 | # OPTION 1 10 | 11 | # role :web, "your web server IP address or hostname here" 12 | # role :web, "second web server here" 13 | # role :web, "third web server here, etc" 14 | 15 | # role :memcached, "your memcached server IP address or hostname here" 16 | # role :memcached, "second memcached server here, etc" 17 | 18 | # OPTION 2 19 | 20 | # If your web servers are the same as your memcached servers, 21 | # comment out all the "role" lines and use "server" lines: 22 | 23 | # server "your web/memcached server IP address or hostname here", :web, :memcached 24 | # server "second web/memcached server here", :web, :memcached 25 | -------------------------------------------------------------------------------- /config/deploy/production.rb: -------------------------------------------------------------------------------- 1 | loadFile 'config/production.rb' -------------------------------------------------------------------------------- /config/deploy/staging.rb: -------------------------------------------------------------------------------- 1 | loadFile 'config/staging.rb' -------------------------------------------------------------------------------- /lib/deploy-after.rb: -------------------------------------------------------------------------------- 1 | before( "deploy", "git:submodule_tags" ) if git_enable_submodules 2 | -------------------------------------------------------------------------------- /lib/deploy.rb: -------------------------------------------------------------------------------- 1 | # 2 | set :user, "deploy" 3 | set :use_sudo, false 4 | set :deploy_via, :remote_cache 5 | set :copy_exclude, [".git", ".gitmodules", ".DS_Store", ".gitignore"] 6 | set :keep_releases, 5 7 | 8 | after "deploy:update", "deploy:cleanup" 9 | after "deploy:update_code", "shared:make_shared_dir" 10 | after "deploy:update_code", "shared:make_symlinks" 11 | after "deploy:update_code", "db:make_config" 12 | after "deploy", "memcached:update" 13 | 14 | # Pull in the config file 15 | loadFile 'config/config.rb' 16 | -------------------------------------------------------------------------------- /lib/misc.rb: -------------------------------------------------------------------------------- 1 | def loadFile file 2 | begin 3 | raise "Could not find #{file}" unless FileTest.readable? file 4 | load file 5 | rescue Exception => e 6 | puts '[Error] ' + e.message 7 | exit 1 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/tasks.rb: -------------------------------------------------------------------------------- 1 | namespace :shared do 2 | task :make_shared_dir do 3 | run "if [ ! -d #{shared_path}/files ]; then mkdir #{shared_path}/files; fi" 4 | end 5 | task :make_symlinks do 6 | run "if [ ! -h #{release_path}/shared ]; then ln -s #{shared_path}/files/ #{release_path}/shared; fi" 7 | run "for p in `find -L #{release_path} -type l`; do t=`readlink $p | grep -o 'shared/.*$'`; sudo mkdir -p #{release_path}/$t; sudo chown www-data:www-data #{release_path}/$t; done" 8 | end 9 | end 10 | 11 | namespace :nginx do 12 | desc "Restarts nginx" 13 | task :restart do 14 | run "sudo /etc/init.d/nginx reload" 15 | end 16 | end 17 | 18 | namespace :phpfpm do 19 | desc" Restarts PHP-FPM" 20 | task :restart do 21 | run "sudo /etc/init.d/php-fpm restart" 22 | end 23 | end 24 | 25 | namespace :git do 26 | desc "Updates git submodule tags" 27 | task :submodule_tags do 28 | run "if [ -d #{shared_path}/cached-copy/ ]; then cd #{shared_path}/cached-copy/ && git submodule foreach --recursive git fetch origin --tags; fi" 29 | end 30 | end 31 | 32 | namespace :memcached do 33 | desc "Restarts Memcached" 34 | task :restart do 35 | run "echo 'flush_all' | nc localhost 11211", :roles => [:memcached] 36 | end 37 | desc "Updates the pool of memcached servers" 38 | task :update do 39 | unless find_servers( :roles => :memcached ).empty? then 40 | mc_servers = ' :memcached ).join( ':11211", "' ) + ':11211" ); ?>' 41 | run "echo '#{mc_servers}' > #{current_path}/memcached.php", :roles => :memcached 42 | end 43 | end 44 | end 45 | 46 | namespace :db do 47 | desc "Syncs the staging database (and uploads) from production" 48 | task :sync, :roles => :web do 49 | if stage != :staging then 50 | puts "[ERROR] You must run db:sync from staging with cap staging db:sync" 51 | else 52 | puts "Hang on... this might take a while." 53 | random = rand( 10 ** 5 ).to_s.rjust( 5, '0' ) 54 | p = wpdb[ :production ] 55 | s = wpdb[ :staging ] 56 | puts "db:sync" 57 | puts stage 58 | system "mysqldump -u #{p[:user]} --result-file=/tmp/wpstack-#{random}.sql -h #{p[:host]} -p#{p[:password]} #{p[:name]}" 59 | system "mysql -u #{s[:user]} -h #{s[:host]} -p#{s[:password]} #{s[:name]} < /tmp/wpstack-#{random}.sql && rm /tmp/wpstack-#{random}.sql" 60 | puts "Database synced to staging" 61 | # memcached.restart 62 | puts "Memcached flushed" 63 | # Now to copy files 64 | find_servers( :roles => :web ).each do |server| 65 | system "rsync -avz --delete #{production_deploy_to}/shared/files/ #{server}:#{shared_path}/files/" 66 | end 67 | end 68 | end 69 | desc "Sets the database credentials (and other settings) in wp-config.php" 70 | task :make_config do 71 | set :staging_domain, '' unless defined? staging_domain 72 | {:'%%WP_STAGING_DOMAIN%%' => staging_domain, :'%%WP_STAGE%%' => stage, :'%%DB_NAME%%' => wpdb[stage][:name], :'%%DB_USER%%' => wpdb[stage][:user], :'%%DB_PASSWORD%%' => wpdb[stage][:password], :'%%DB_HOST%%' => wpdb[stage][:host]}.each do |k,v| 73 | run "sed -i 's/#{k}/#{v}/' #{release_path}/wp-config.php", :roles => :web 74 | end 75 | end 76 | end 77 | --------------------------------------------------------------------------------