Simple deployments with git

Posted on Thu 05 November 2015 in Git on the server

Git is not a deployment tool. Content tracking and revision control is not the same as code deployment. And pushing new code to a remote repository is not the same as deploying code. If uptime, consistency, rollbacks and predictability matter to you, you will not attempt to build something that deploys new code the moment it is pushed.

But if you don't care about all of the above, such as for a personal website or a toy project where the convenience of 'push equals deploy' is much more important, here are a few tricks to still keep it as maintainable and predictable as possible.

Push to non-bare repository

The simplest way of deploying with git is to push to a non-bare repository. This is not recommended, but in really simple environments where consistency doesn't matter and where that non-bare repository cannot have local changes, it can be a feasible strategy. In the bare repository that you want to push to, you will need to tell git to accept this.

$ git config receive.denyCurrentBranch updateInstead

If you also need to do some extra build steps, you really should consider a better deployment strategy, but you can use the push-to-checkout hook to do this as well.

Post-receive hook that triggers fetch/reset

A somewhat saner way to do simple deploys is to have a bare repository to push to, and a separate non-bare repository that is the deployed version of your code. You can then use a post-receive hook in the bare repository to update the non-bare one. Here is an example post-receive hook that does this.

 Download post-receive-reset
#!/bin/bash # # Configuration # ------------- # hooks.deployDir # The directory where your non-bare repo lives that should be deployed to # hooks.deployBranch # Deploy this branch # hooks.deployPost # Command to run after the deploy, e.g. to restart a webserver or minify files die() { echo >&2 "fatal: post-receive-deploy: $1" exit 1 } test -z "$GIT_DIR" && die "GIT_DIR not set" deploydir=$(git config hooks.deploydir) deploybranch=$(git config hooks.deploybranch) deploypost=$(git config hooks.deploypost) test -z "$deploydir" && die "hooks.deploydir not set" test -d "$deploydir" || die "deploy directory $deploydir does not exist" test -z "$deploybranch" && die "hooks.deploybranch not set" while read oldrev newrev refname; do if [ "$refname" = "refs/heads/$deploybranch" ]; then echo "Deploying $deploybranch@${newrev:0:8} to $deploydir" ( unset GIT_DIR cd "$deploydir" if [ -n "$(git status -s 2>&1)" ]; then die "working directory dirty" fi git fetch git reset --hard "origin/$deploybranch" if [ -n "$deploypost" ]; then sh -c "$deploypost" fi ) fi done

If you want to write your own hook instead of using the one above, please keep in mind the following:

  • The hook must consume all input on stdin. So no exiting after the deploy.
  • The hook should update the deployment only when the deployment branch is updated. Not for other branches.
  • The commands to update must run in a subshell
  • GIT_DIR must be unset in that subshell
  • The hook should abort when the worktree is dirty
  • And most importantly: hooks must never require human input. So don't use git merge, git pull or git rebase in a hook. Fetch and reset are safe and ensure that your deploys will keep working even after a push -f.

Post-receive hook using git-archive

The sanest way to deploy using nothing but git hooks is to actually prepare deployments and have a separate step to deploy them. The usual way is to have all deployments in subdirs of a directory, and to have a symlink pointing to the main deploy. After six deploys it may look like this

/srv
└── www
    ├── active ⇒ master@e902921d
    ├── master@e902921d
    ├── master@de9cdb7e
    ├── master@bd204ab2
    ├── master@979c99f5
    ├── master@54050851
    └── master@29080deb

The web server would be configured to use /srv/www/active as the root of this website, and e902921d is the currently active deploy.

A scheme like this allows for quick rollbacks (simple ln -sf invocations), long-running compilation steps (without breaking quick rollbacks!) and a quick glance at what was active when (timestamps on the deploy directories). The hook that accomplishes this is:

 Download post-receive-deploy
#!/bin/bash # # Configuration # ------------- # hooks.deployDir # The directory where your deployments live # hooks.deployLink # The symlink in that directory that points to the current deploy # hooks.deployBranch # Deploy this branch # hooks.deployPost # Command to run after the deploy, but before the deploy is activated # hooks.deployActivate # Command to run after the deploy has been activated die() { echo >&2 "fatal: post-receive-deploy: $1" exit 1 } test -z "$GIT_DIR" && die "GIT_DIR not set" deploydir=$(git config hooks.deploydir) deploylink=$(git config hooks.deploylink) deploybranch=$(git config hooks.deploybranch) deploypost=$(git config hooks.deploypost) deployactivate=$(git config hooks.deployactivate) test -z "$deploydir" && die "hooks.deploydir not set" test -z "$deploylink" && die "hooks.deploylink not set" test -d "$deploydir" || die "deploy directory $deploydir does not exist" test -z "$deploybranch" && die "hooks.deploybranch not set" while read oldrev newrev refname; do if [ "$refname" = "refs/heads/$deploybranch" ]; then deploy="$deploybranch@${newrev:0:8}" echo "Deploying $deploy to $deploydir/$deploy" if [ -e "$deploydir/$deploy" ]; then # This happens when re-pushing a tip that has been tip before echo "$deploydir/$deploy exists, skipping" exit 0 fi git archive --prefix="$deploy/" $newrev | tar -C "$deploydir" -x if [ -n "$deploypost" ]; then ( cd "$deploydir/$deploy" && sh -c "$deploypost" ) fi echo "Activating $deploydir/$deploy as $deploydir/$deploylink" ln -sf -T "$deploy" "$deploydir/$deploylink" if [ -n "$deployactivate" ]; then ( cd "$deploydir/$deploylink" && sh -c "$deployactivate" ) fi fi done

This hook does not need to care about GIT_DIR, dirty work trees or interactive commands. All it does is create new deploys and change a symlink to activate them. Where the previous hook only had one place to execute additional commands (right after reset), this one has two: one after creating the deploy to do long-running commands, and one after activating the symlink to do things like restart a web server.