Introduction

As I discussed previously in What’s powering my website, I handle everything from server administration to production deployment. This post outlines how I setup a remote Git repo with key-based SSH access and configured a post-receive Git hook to trigger a build pipeline. Using simple bash, systemd units, and a systemd path trigger, there’s very little to go wrong.

No web interface, nothing fancy; just a remote repository that you host yourself, push to, and it goes live. No middleman required. Sounds good to me. I’d like to thank karthink for making me aware of the simple yet overlooked Git concept during the 2025 December EmacsATX meetup.

Configuring a set up like this allows for a ridiculously efficient publishing experience:

  1. Write a post and associated metadata in Emacs org-mode
  2. Export it with ox-hugo
  3. Review, add, commit, and push to remote
  4. ???
  5. Profit

Design Overview

The pipeline enforces a build -> stage -> production life cycle and is a system-owned process. When a user pushes to the repository, a user-owned service is executed to prepare the build working tree via a Git hook. On completion, a system-owned service is triggered automatically to build and atomically swap new deployments. The git user is thus constrained to the user-space and only executes necessary commands in preparation for pipeline processing.

The workflow is outlined as follows:

  1. Push to git@<server_address>:/var/www/git/web-playleft.git
  2. post-receive Git hook runs and executes sudo systemctl start playleft-prepare-worktree.service
  3. playleft-prepare-worktree.service runs as the git user and executes playleft-prepare-worktree.sh:
    • git fetch && git pull from the working build tree
    • grabs the latest commit hash
    • compare latest hash with /var/playleft/last_built_commit
    • write the latest hash to /var/playleft/pending_commit to trigger a path service
  4. playleft-build.path (as root) reads /var/playleft/pending_commit and triggers the playleft-build.service
  5. playleft-build.service (as root) executes playleft-build-deploy.sh:
    • build with Hugo and output to /var/www/playleft.com/releases/<TIMESTAMP>
    • symlink latest built release to /var/www/playleft.com/current
    • update the last_built_commit
    • remove the pending_commit

Requirements

  • Systemd
  • Git
  • SSH key
  • Hugo
  • Nginx configured to serve data
  • Patience

Getting set up

This section goes through creating a new user, establishing SSH access, and pushing to our first remote repository.

Thanks to the official Git documentation.

Step 1: User and SSH access set up

Start by creating a new user on the server. This is the user that you connect with via SSH to perform remote Git operations. You could name the user anything you like and add as many as needed.

sudo adduser git

Switch to the new user and add your public SSH key to the authorized_keys file.

su git
mkdir ~/.ssh && chmod 700 ~/.ssh
touch ~/.ssh/authorized_keys
echo "your_public_key_here" > ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
  • Note: From this point up to and including step three, all commands should be run as the new user we just created which I refer to as git.

Step 2: Directory set up and permissions

Still as the git user, create the root directory that you want to store repositories in and give them ownership of it:

sudo mkdir /var/www/git
sudo chown git:git /var/www/git/

Step 3: Init a bare repo

Create a repository and initialise it:

mkdir /var/www/git/web-playleft.git
cd /var/www/git/web-playleft.git
pwd  # make sure you're in the correct dir
git init --bare

Now we have a bare repository to push to! At this point, this is the equivalent of setting up an empty repository with your favourite Git provider.

Step 4: Set the server as a remote

On the machine that you permitted SSH access to in step one, execute:

cd /path/to/repo
git remote add server git@<server-address>:/var/www/git/web-playleft.git
git push server master

All going well you should see the bare repository on the server populated with the object database and references from the local repo that we just pushed. Neato!

If you only want to host a remote repository, you’re done. Simply repeat steps two through four as the correct user to add more repositories.

Otherwise, onwards to deployment!

Deployment

To provide rollbacks1 we use a build -> staging -> production workflow. We configure Nginx to serve from a path that points to a symlink of the latest deployment of the site (e.g., /current). The path of the new build (e.g. /release/202512071345) will replace the current active path. This process is known as an atomic swap2, and insures that when we push an update there’s no interruption to users resulting from disk operations if we were to simply overwrite or move the old builds.

In short, performing an atomic swap is practically instantaneous, whereas disk operations are much slower (relatively). Atomic swaps also insure that a resource is always in a readable state since either the entire original or the entire new version is available at any given moment during the operation. Thus users aren’t receiving corrupted data, or worse, a 404, when they request a resource. Therefore, we conduct disk operations first, and swap /current symlink to the latest production build path. So the deployment pipeline only builds new deployments and points the symlink to a new build. Too easy right?!

The final directory structure will look like this:

/var/www/playleft.com
├── current -> /var/www/playleft.com/releases/20251206T132116  # served by nginx
└── releases  # contains timestamped deployments
    └── 20251206T132116
/var/www/playleft.com-build  # working tree containing website source code
/var/www/git/web-playleft.git  # bare git repo we clone from

Step 0: Set up build and staging directories

The releases/ directory contains each deployment filed under the build timestamp. Previous stages are thus never overwritten which provides an efficient rollback method via simple symlink adjustment.

The playleft.com-build/ directory is the working tree for the master branch of the repo that we set up earlier.

As your root user (not root):

su <root-user>
sudo mkdir /var/www/playleft.com/releases
sudo mkdir /var/www/playleft.com-build
sudo chown git:git /var/www/playleft.com-build

Step 1: Clone the latest version

Clone the latest source from our server-hosted repo.

cd /var/www
sudo git clone /var/www/git/web-playleft.git playleft.com-build

Step 2: Create a date-based staging directory

Now we need to store the latest version in a versioned directory under releases/. Create a staging directory using the date:

RS="$(date +%Y%m%dT%H%M%S)"
# output example: 20251231T132116
sudo mkdir "/var/www/playleft.com/releases/$RS"

Step 3: Build the latest version

Build with Hugo, directing the output to the previously created staging directory. After building, the latest version is considered staged and ready to go to prod.

# -s SOURCE_TO_BUILD -d OUTPUT_DIR
sudo hugo -s "/var/www/playleft.com-build" -d "/var/www/playleft.com/releases/$RS"

Step 4: Staging goes to production

Create a symbolic link to our latest version to /current, which Nginx serves for us.

sudo ln -sfn "/var/www/playleft.com/releases/$RS" "/var/www/playleft.com/current"

Automating Deployment

Up until this point, we have a manual build to production pipeline. But I didn’t get into computers to keep working in manual labour. This section will outline how to set up a server-side hook in our repo which calls a systemd unit to execute the deployment pipeline. The process is:

  1. Make a push to the server which triggers a post-receive, calling a systemd unit
  2. Systemd unit executes a worktree preparation script, and outputs the pending commit hash to be built to a file,
  3. A path-triggered build unit triggers a systemd unit and executes the build and deploy script based on the pending hash, and
  4. The new build is atomically swapped to current and served by nginx.

Originally I thought to place the script in the post-receive itself. In the end I decided against it because if the script fails to run or takes time to complete, so too does our push. Instead, I set up a one shot systemd unit that our Git hook will call after the push process completes. The hook doesn’t care about the unit’s output or even if the process succeeded, only that it was called.

Constraining the script to a systemd unit provides efficient logging without the overuse of echo commands via systemctl status. It also establishes a single point of entry where we can restrict the use of arbitrary (elevated) command execution effectively and other additional hardening provided by systemd.

A quick note on automation and security

[This is especially important in multi-user setups and or more complex deployment pipelines. In short, I rely on sound configuration, careful permissions setup, and the security of SSH. You may not be so lucky to allow such leniency in your production pipelines, so I think this is worth noting.]

It is good practice to sanitise the contents of your working tree prior to execution of build commands in our deployment scripts to mitigate against attacks. Theoretically, if you give a user access, they could exploit automation pipelines if we don’t account for this possibility by sanitising inputs and other pre-build checks.

In the case of my pipeline, this would mean exploiting the Hugo build process presumably to gain control of the server, or to modify the content of the site in ways we don’t expect.

Given I’m the sole user of the server and have configured adequate SSH security and taken other precautions I’m not overly concerned, and so the deployment script effectively trusts the working tree without sanitising it.

Create a prepare build worktree script (playleft-prepare-worktree.sh)

This script prepares the worktree for the build process by pulling the latest commit and comparing the HEAD and previous commit hash. This insures that the build only executes if the remote repo is ahead, preventing redundant building, saving precious CPU cycles, disk operations, and space3.

The script outputs the active commit hash to a file /run/playleft/pending_commit which triggers a systemd path unit to kick off the build and deploy pipeline.

Touch a blank script:

sudo touch /usr/local/bin/playleft-prepare-worktree.sh

Insert the following bash script with your favourite text editor:

#!/bin/bash
set -euo pipefail

WORKTREE="/var/www/playleft.com-build"
RUNSTATE="/run/playleft"
BRANCH="master"    # change to main if that's your branch

echo "[prepare] Preparing worktree for branch ${BRANCH}"

cd "$WORKTREE"

# Ensure we know about latest refs
git fetch
git pull --ff-only

NEW_COMMIT="$(git rev-parse $BRANCH)"

LAST_BUILT_FILE="$RUNSTATE/last_built_commit"
LAST_BUILT_COMMIT=""
if [[ -f "$LAST_BUILT_FILE" ]]; then
    LAST_BUILT_COMMIT="$(cat "$LAST_BUILT_FILE")"
fi

if [[ "$NEW_COMMIT" == "$LAST_BUILT_COMMIT" ]]; then
    echo "[prepare] No new commit to build (last built: $LAST_BUILT_COMMIT). Exiting."
    exit 0
fi

echo "[prepare] Build required. New commit detected: $NEW_COMMIT (last built: $LAST_BUILT_COMMIT)"

# Record pending commit for builder
PENDING_COMMIT="$RUNSTATE/pending_commit"
echo "$NEW_COMMIT" > "$PENDING_COMMIT"

echo "[prepare] Pending commit recorded: $NEW_COMMIT"
exit 0

Make the script executable:

sudo chmod +x /usr/local/bin/playleft-prepare-worktree.sh

Create a build and deploy script (playleft-build.sh)

Here we execute and automate the build -> staging -> production pipeline described in steps three and four of the design overview. It checks for the latest pending_commit hash, compares it against the last_built_commit, timestamps a build directory, builds the latest worktree with Hugo, and symlinks the new build to the current directory.

Once completed, the pending_commit hash is written to the last_built_commit, and the pending_commit file is deleted in preparation for future builds.

Touch a blank script:

sudo touch /usr/local/bin/playleft-build.sh

And insert the following:

#!/bin/bash
set -euo pipefail

BUILD_DIR="/var/www/playleft.com-build"
RELEASE_DIR="/var/www/playleft.com/releases"
PROD_DIR="/var/www/playleft.com"
RUNSTATE="/run/playleft"

PENDING_FILE="$RUNSTATE/pending_commit"
LAST_BUILT_FILE="$RUNSTATE/last_built_commit"

if [[ ! -f "$PENDING_FILE" ]]; then
    echo "[build] No pending_commit file found. Nothing to do."
    exit 0
fi

PENDING_COMMIT="$(cat "$PENDING_FILE")"
TIMESTAMP="$(date +%Y%m%dT%H%M%S)"
STAGING="$RELEASE_DIR/$TIMESTAMP"

echo "[build] Starting build for commit $PENDING_COMMIT at $TIMESTAMP"

# Ensure release dir exists
mkdir -p "$RELEASE_DIR"
mkdir -p "$STAGING"

# Build with Hugo
hugo -s "$BUILD_DIR" -d "$STAGING"

# Atomic symlink swap: current -> new release
ln -sfn "$STAGING" "$PROD_DIR/current"

# Mark commit as successfully built
echo "$PENDING_COMMIT" > "$LAST_BUILT_FILE"
chown root:git "$LAST_BUILT_FILE"
chmod 660 "$LAST_BUILT_FILE"

# Clear pending hash file
rm -f "$PENDING_FILE"

echo "[build] Deployment complete: $TIMESTAMP. Active commit: $PENDING_COMMIT"
exit 0

Make the script executable:

sudo chmod +x /usr/local/bin/playleft-build.sh

Write the prepare worktree unit (playleft-prepare-worktree.service)

This is the systemd service that the git user owns. It executes the build worktree script that we prepared earlier, and thus is not responsible for system/server related tasks such as deploying, building, or updating the current release symlink. The unit’s role is simply to update the worktree to the latest commit, and write the pending build commit to a file that will be read by the build path unit.

Touch a blank unit:

sudo touch /etc/systemd/system/playleft-prepare-worktree.service

Insert the following:

[Unit]
Description=Playleft: prepare working tree after push

[Service]
Type=oneshot
User=git
ExecStart=/usr/local/bin/playleft-prepare-worktree.sh

PrivateTmp=true
PrivateDevices=true
ProtectSystem=full
ReadWritePaths=/var/www/playleft.com-build /run/playleft /home/git/.config/
NoNewPrivileges=false

Write the build path trigger unit (playleft-build.path)

This path unit is triggered when the path exists, in this case the /run/playleft/pending_commit path written by the prepare worktree unit, and the service unit with the matching name is subsequently started. For more details on path units, see the systemd.path man page.

Touch a blank unit:

sudo touch /etc/systemd/system/playleft-build.path

Insert the following:

[Unit]
Description=Playleft build path trigger

[Path]
PathExists=/run/playleft/pending_commit

[Install]
WantedBy=multi-user.target

Enable the trigger:

sudo systemctl enable --now playleft-build.path

Write the build service unit (playleft-build.service)

This is a root owned unit responsible for executing the build and deploy script. It’s root owned because our deployment script requires sudo access, and so it includes some basic hardening configuration. I came across this great article from 2012 about securing such units, it’s a little outdated, but cross referencing against the modern systemd man pages made it easy to translate.

Admittedly I am not an expert, but at the very least this seems like a good baseline. The main thing being applied is the least access principle and restricting the unit’s read/write access to only what is necessary to carry out deployment. If you have any suggestions for improvements please let me know!

Touch a blank unit:

sudo touch /etc/systemd/system/playleft-build.path

Insert the following:

[Unit]
Description=Playleft Hugo atomic deployment

[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/playleft-build.sh
Nice=10

NoNewPrivileges=false
# Hardening
PrivateTmp=true
PrivateDevices=true
ProtectHome=read-only
ProtectSystem=full
ReadWritePaths=/var/www/playleft.com /run/playleft

# Prevent overlapping runs
RuntimeDirectory=playleft-deploy
ExecStartPre=/usr/bin/flock -n /run/playleft-deploy/lock -c true

At this stage you could run sudo systemctl start playleft-deploy.service as a test without concern.

Create a post-receive hook

Git is so bloody cool. Long story short, GitHub’s “Actions” are a built in feature of Git in the form of hooks. If you look inside a bare repo you’ll notice a hooks/ directory; inside are a bunch of example hooks. Each is named based on the point in the Git process that they execute. Some client hooks for example are post-commit and pre-rebase which are self explanatory. You can review Git’s documentation to go in depth.

What I’m interested in are server-side hooks, specifically a hook that runs after a successful push and starts our prepare worktree service.

To do so, we simply set up a post-receive hook as the git user:

su git
touch /var/www/git/web-playleft.git/hooks/post-receive

Insert the following4:

#!/bin/bash
set -euo pipefail

# Fire and forget: we don't wait for deployment here
sudo /usr/bin/systemctl start playleft-prepare-worktree.service

Then make it executable:

chmod +x /var/www/git/web-playleft.git/hooks/post-receive

Voila!

Now every time I push to the server to make a post or update the code, that’s all I need to do. The rest is lovingly handled by Git’s built in feature set, some basic bash script, and systemd.

Write, export, push, done.

ezpz.


  1. To rollback a release you simply swap the symlink back to the previous release yourself. <<

  2. For more information, read this stackoverflow post and the GNU coreutils manual on ln. Please correct me if any of the technical details I outlined are wrong. <<

  3. : Cleaning up old releases is currently manual. I’ll revise this post at a later date to include automatic garbage collection. <<

  4. : I don’t believe hooks have a proper shell session/environment, so we use the absolute path to the systemctl binary. <<