alex coomans

about

Deploying a Multi-Environment PHP site with Git

20 May 2011 - Austin, TX

I've been working as the System Administrator/Operations Engineer/etc. for a PHP based site and I implemented a fairly simple system for deploying to different application environments.

The Server

First, it will probably help to know a little about the server so that some of the later parts makes a little more sense. The server runs nginx to serve static files, and then proxies to php-fpm to run the PHP files. For staging, it gets a little more complicated, as all requests are proxied through a central-authentication system (which I'd love to write about), but it essence is the same.

Managing environments, however, isn't as simple as with Rails, mainly because Rails is engineered around the concept of environments. I didn't find anything for working with environments in PHP, so I coded up an Environment class to make all of this simpler (feel free to copy for your projects).

<?php
class Environment {
    public function production() {
            return $_SERVER['SYS_ENV'] === 'production';
    }

    public function staging() {
            return $_SERVER['SYS_ENV'] === 'staging';
    }

    public function development() {
        return !($this->production() || $this->staging());
    }

    public function db_settings() {
        if($this->production()) {
            return array("host" => "localhost", "database" => "db", "user" => "db", "password" => "pw");
        } elseif($this->staging()) {
            return array("host" => "localhost", "database" => "db-staging", "user" => "db", "password" => "pw");
        } else {
            return array("host" => "127.0.0.1:5000", "database" => "db-development", "user" => "db", "password" => "pw");
        }
    }
}
?>

The class simply checks against an environment variable that I set in our nginx file (shortened).

server {
    location ~ .php$ {
        fastcgi_param  SYS_ENV  production;
    }
}

I also added the db_settings function so that our database configuration would be in one place, and then I simply call the function and it returns the appropriate settings, and you could copy and paste that method for other environment-specific variables.

Deployment

For this project, the team has a privately hosted git repo on GitHub, so I built a custom post-receive hook on an internal application that manages the deploys for us, and authentication is handled by HTTP Basic Authentication (hence the grayed out part of the url).

GitHub Post-Receive HooksThe GitHub Post-Receive Hooks Management Page

The control application to which I referred to does a lot more than handle deployments, and I may open source the part that allows us to rollback deployments, but below is a simplified version of the Sinatra application. If you are unfamiliar, Sinatra is a Ruby web framework that helps you build and deploy simple applications quickly.

require 'rubygems'
require 'sinatra'
require 'grit'
require 'crack'
require 'time'

post '/github' do
  basic_auth!
  payload = Crack::JSON.parse(params[:payload])
  if repos.keys.include?(payload["ref"].split("/").last)
    `cd #{repos[payload["ref"].split("/").last].path.gsub('.git', '')} && git pull && chown www-data:www-data -R .`
  end
  "ok"
end

private
  def repos
    @repos ||= {
      "production" => Grit::Repo.new("/var/www/example.com"),
      "staging" => Grit::Repo.new("/var/www/staging.example.com")
    }
  end

  helpers do

    def basic_auth!
      unless authorized?
        response['WWW-Authenticate'] = %(Basic realm="Restricted Area")
        throw(:halt, [401, "Not authorized\n"])
      end
    end

    def authorized?
      @auth ||=  Rack::Auth::Basic::Request.new(request.env)
      @auth.provided? && @auth.basic? && @auth.credentials && @auth.credentials == ['github', 'auth']
    end

  end

If you've never taken a look at the Post-Receive hook documentation, it simply lays out the information GitHub will POST to your server. In this case, I check to see if the push was to either the "staging" or "production" branches, and if so deploys it. I also have to perform the chown command since the web server runs under a different user than the application. It Github, you would add a URL like: http://github:auth@example.com/github to your post-receive hooks so that they can correctly authenticate. If you don't need authentication, you can simply remove that part of the Sinatra server and the github:auth@ part of the URL

A git pull may not be the best command for this type of operation (I'd love to hear if something would work better), but because I know no one will have touched any of the files in those folders, I can't imagine having any problems. Also, you could change this server away from using the Grit library, but I have it since other parts of the application rely on those being Grit::Repo objects.

When code is ready to be pushed to staging we simply merge the master branch into the staging branch and push, and when changes are ready for production, we merge in the staging branch into the production branch and push. Normally within a few seconds the changes have been updated on the server, and you can continue hacking.

Any thoughts on other environment strategies or improving this process?


Comments

blog comments powered by Disqus
copyright © 2011 alex coomans