This project started with a simple question. Is it possible to achieve some of the convenience of modern CI/CD processes on a single machine, without writing lots of code? What I wanted was to be able to push a git commit and have that trigger builds and deployment, in my simple case something simple like running a process that would generate some files and then putting those files in the right place.


       ------               -----               ------                      ------ 
      |commit| =triggers=> |build| =produces=> |output| =is delivered to=> |server|
       ------               -----               ------                      ------ 
      

Making this happen on a single linux host really only required me to figure out two things: What does a git commit change on a server, and is it possible to trigger processes based on that change?

Luckily, figuring this out was really easy: Inside of a git repo there is a folder that contains files where branch "pointers" are connected to commit hashes (check it out in your own repos' .git/refs/heads). Whenever there is a change made to a particular branch, that file changes. Great!

Next, how do I trigger code based on file changes on Linux? This question brought me down a bit of a rabbit hole. There are of course myriad ways to do this, so my question really became: What would be the simplest and most idiomatic approach? Enter Systemd! I have learned a lot about SystemD lately, including how to run services with it, read logs and so on. Interestingly, in addition to managing "daemon" services that are supposed to run all of the time, Systemd also has facilities for managing services that are supposed to run based on events. There are (at the time of writing) three events that can trigger service invocation: Timers, Sockets and Paths. Paths are exactly what we need, a path trigger will run a service whenever a path changes.

All I had to do after figuring this out was write a set of instructions, for what I wanted to run whenever a commit happened, and hook that up with a path trigger. Here's what that looks like in my unit files:

cd-blog.path


      [Unit]
      Description=Watch for changes in the blog directory.

      [Path]
      PathChanged=/home/git/blog.git/refs/heads/main

      [Install]
      WantedBy=multi-user.target
      

cd-blog.service


      [Unit]
      Description=A CD job that builds my blog on each push.
      #StartLimitIntervalSec=1
      #StartLimitBurst=1

      [Service]
      Type=oneshot
      #RemainAfterExit=true
      User=ci
      ExecStart=-rm -r /home/ci/blog
      ExecStart=-mkdir -p /home/ci/output/blog
      ExecStart=git clone /home/git/blog.git -b main /home/ci/blog
      ExecStart=/usr/bin/docker build  -t cd-blog /home/ci/blog
      ExecStart=/bin/bash -c '/usr/bin/docker run --user $(id -u) --rm -v /home/ci/output/blog:/output cd-blog'
      ExecStartPost=-rm -r /home/ci/blog

      [Install]
      WantedBy=multi-user.target
      

A systemd path unit will trigger a service with the same name whenever its target path (PathChanged) changes. In the service, you can see the steps taken to deliver the committed code. I clone the repo into a folder on my server, and run some stuff in it (I decided to "dockerize" the process since I am using Python, you can do whatever you want in these steps!

The result is that whenever I push a commit to main, my blog is refreshed, as the outpout folder of the CI step is served by my Nginx server. You are currently looking at the result.

From this project, I learned a lot about the value of learning fundamentals. Knowing systemd well, you are able to do a lot more with your Linux servers without having to resort to lots of cool, custom code. Often, learning what you have is better than searching for or building something else. A tip is to use the man pages when working with systemd. They are great!