January 19, 2014

Git, NGINX, web development and deploy-by-push (part 2)

If you are reading this, then you probably read the first part, and if not, then you should!

In the first part I showed how to setup Gitolite and SSH keys. In this part I'll show you how I made Gitolite and NGINX deploy my webpages and some extra tips.

Before we start, I want to remind you that we will be working on the server side (where you installed Gitolite), as the git user. Also keep in mind I set my git user's $HOME path to /srv/git. One last thing: I'll be serving my webpages from /srv/http.

Let's start!


Before we start with Gitolite and NGINX, I'll set permissions and ownership to /srv/http. I assume the user/group http already exists in your machine. If it doesn't, create them.

$ sudo members http
http
$ sudo usermod -a -G http git
$ sudo chown -R http:http /srv/http
$ sudo chmod -R 770 /srv/http

Later we will configure NGINX to use the http group to read from /srv/http.

The very first thing that we want Gitolite to do is to actually create a folders structure on each new repository we create. That is, whenever we add a new repository in the conf file inside Gitolite's admin repository, Gitolite will create that repository, clone it, create a basic folders structure and commit it.

I spent some time thinking about a good folders structure and I came up with a solution that will let me have my website, my conf and my logs.

Tip: actually, it's really easy to add mail or whatever you want.

First we need to edit Gitolite's conf, not in the admin repository, but Gitolite's conf itself.

$ pwd
/srv/git
$ ls -a
.bash_history
.gitconfig
.gitolite
.gitolite.rc
.local
.ssh
projects.list
repositories

We want to edit the .gitolite.rc file. There are 2 things that we need to do.
  1. Uncomment the LOCAL_CODE variable which points to $ENV{HOME}/local, or create it if it doesn't exist.
  2. Create a POST_CREATE variable with the value ['post_create'].
After you are done editing the file, it should look like this:

%RC = (
    LOCAL_CODE => "$ENV{HOME}/local",
    POST_CREATE => ['post_create'],
    ...
    ...
    ...
)

Next thing we must do is create the scripts that will actually create the folders structure and add it to each new repository.

$ pwd
/srv/git
$ mkdir -p local/hooks/common local/triggers

Create a file called post_create inside the local/triggers folder with the following instructions:

#!/bin/bash

repo="$2"

cd /tmp
git clone git@localhost:$repo
cd $repo
mkdir conf www logs
echo "" > conf/.gitignore
echo -e '#!/bin/bash\n' > conf/autorun.sh
echo -e '#!/bin/bash\n' > conf/post-commit.sh
chmod 770 conf/autorun.sh conf/post-commit.sh
echo -e '*\n!.gitignore' > logs/.gitignore
echo "404" > www/404.html
echo "50x" > www/50x.html
git --git-dir=/tmp/$repo/.git --work-tree=/tmp/$repo add -A
git --git-dir=/tmp/$repo/.git --work-tree=/tmp/$repo commit -m "Initial commit"
git --git-dir=/tmp/$repo/.git --work-tree=/tmp/$repo push
cd ..
rm -rf $repo

As you can see, this script is going to be triggered after a new repository is created and it will create some basic folders and some files/scripts.

The logs folder gets a .gitignore file that ignores everything except itself for two reasons. First, we want the logs folder itself to get added to git (as a reminder), and second, we don't want the server logs for that domain to get added to git. Of course, you can change this to whatever best fits your needs.

The conf folder gets an empty .gitignore file because we want to keep the folder itself and everything that we will place inside it later (NGINX conf, BindDNS conf, scripts, etc...). I'll explain this later.

Tip: You can create a mail folder the same way as the logs folder gets created.

Create yet another file called post-receive inside the local/hooks/common folder with the following instructions:

#!/bin/bash

repo=$(basename "$PWD")
repo=${repo%.git}

nginx=0

echo -----Start deploy-----
if [ -d /srv/http/$repo ]; then
    chown -R git:http /srv/http/$repo
    chmod -R 770 /srv/http/$repo
fi

if [ -d /srv/http/$repo ]; then
    echo "Updating existing repo..."
    cd /srv/http/$repo
    git --git-dir=/srv/http/$repo/.git --work-tree=/srv/http/$repo fetch origin
    git --git-dir=/srv/http/$repo/.git --work-tree=/srv/http/$repo update-index --refresh &> /dev/null #update git index to check actual content changes instead only file permission changes
    git --git-dir=/srv/http/$repo/.git --work-tree=/srv/http/$repo reset --hard origin/master
else
    echo "Cloning new repo..."
    cd /srv/http/
    git clone git@localhost:$repo
fi

chown -R git:http /srv/http/$repo
chmod -R 770 /srv/http/$repo
chmod -R 550 /srv/http/$repo/www #we are done updating, set www to read only

postcommit_arg_oldrev=0
while read oldrev newrev refname; do
    diff=$(git --git-dir=/srv/http/$repo/.git --work-tree=/srv/http/$repo diff --name-only $oldrev $newrev)

    if [ $nginx -eq 0 ]; then
        nginx=$(echo $diff | grep 'conf/.*nginx\.conf' | wc -l)
    fi

    if [ $nginx -ne 0 ]; then
        break;
    fi

    if [ $postcommit_arg_oldrev -eq 0 ]; then
        postcommit_arg_oldrev=$oldrev
    fi
done

if [ -f /srv/http/$repo/conf/post-commit.sh ]; then
    cd /srv/http/$repo/conf
    /srv/http/$repo/conf/post-commit.sh $postcommit_arg_oldrev
    cd /srv/http/$repo
fi

if [ $nginx -ne 0 ]; then
    echo "Changes in NGINX conf found, restarting..."
    sudo nginx -t &> /dev/null
    if [ $? -eq 0 ]; then
        sudo systemctl restart nginx
    else
        sudo nginx -t
        echo "Not restarting NGINX due to bad config!"
    fi
fi
echo -----End deploy-----

This script is a little bit longer, but it's easy to understand what it's going to do. It will get triggered after each push. It will check if the repository that we made the push to is cloned inside /srv/http and if it doesn't, it will clone it there; if it does, it will update it. This is where the tricky part is. Remember that I said that I don't want to run a blind git update command that will wipe who-knows-what? Well, what this script does is:
  1. Fetch (but not merge) all the changes from the gitolite server
  2. Check for actual file changes (ignores file permissions changes)
  3. Resets to HEAD only the files that got changed (hello nasty PHP viruses!). Why only the files that changed and not just wipe everything? Because you're interested in preserving the files that got uploaded to your website from an external source. Let's say avatars from a forum or attachments from a WordPress post.
The script will also set some permissions (change to fit your needs) and it will iterate over all the added/changed files from all the commits inside the push and if it finds an added/changed file inside the conf folder that ends with .nginx.conf it will restart NGINX.

Tip: You can add support for BindDNS the same way I added support for NGINX.

There is one more thing that the script will do: run the post-commit.sh script inside the conf folder! This is extremely useful. You can use it for whatever you want. What I use it for is set write permissions to some paths inside www (cache, avatars, uploads, etc...). I also use it to start/restart some NodeJS instances, but you can make it do literally whatever you like!

Note: For the git user to be able to restart NGINX you will either have to allow the git user (from the sudoers file) to run the commands sudo systemctl restart nginx and sudo nginx -t or you'll have to hack a small script, executable but not writeable from the git user, that will restart NGINX.

Finally, we need to make both scripts executable.

$ chmod ug+rx local/hooks/common/post-receive
$ chmod ug+rx local/triggers/post_create

Now that we are done configuring Gitolite, it's time to do the same with NGINX. We just need to tell NGINX
Hey! Look for conf files that you can use in each folder under /srv/http/*/conf
This way we could place one (or multiple) conf files in the conf folder of each repository, making NGINX use them all as directives for vhosts!

Edit your /etc/nginx/nginx.conf file and add this line inside the http directive.

include /srv/http/*/conf/*.nginx.conf;

You will also want to set access_log and error_log to off, because you'll want individual logs per domain/vhost. Just add those two rules to the http directive.

access_log off;
error_log off;

And make it run as the http user and use the http group.

user http http;

When you want to add a vhost, just create a new .nginx.conf file inside your repository's conf folder and write a server directive. Like this one:

server {
    listen 80;
    server_name mydomain.com www.mydomain.com;

    root /srv/http/mydomain.com/www;

    error_page 404 404.html
    error_page 500 502 503 504 50x.html;

    location / {

    }
}

We are done here! If you followed everything step by step, now you'll have a completely functional Git server with deploy by push system.

I'll write yet another part, hopefully the last one, showing the actual process of creating a new repository, so make sure to check that too!