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:
- Write a post and associated metadata in Emacs
org-mode - Export it with
ox-hugo - Review, add, commit, and push to remote
- ???
- 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:
- Push to
git@<server_address>:/var/www/git/web-playleft.git post-receiveGit hook runs and executessudo systemctl start playleft-prepare-worktree.serviceplayleft-prepare-worktree.serviceruns as thegituser and executesplayleft-prepare-worktree.sh:git fetch && git pullfrom 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_committo trigger a path service
playleft-build.path(asroot) reads/var/playleft/pending_commitand triggers theplayleft-build.serviceplayleft-build.service(asroot) executesplayleft-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
- build with Hugo and output to
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:
- Make a push to the server which triggers a
post-receive, calling a systemd unit - Systemd unit executes a worktree preparation script, and outputs the pending commit hash to be built to a file,
- A path-triggered build unit triggers a systemd unit and executes the build and deploy script based on the pending hash, and
- The new build is atomically swapped to
currentand 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.
-
To rollback a release you simply swap the symlink back to the previous release yourself. <<
-
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. << -
: Cleaning up old releases is currently manual. I’ll revise this post at a later date to include automatic garbage collection. <<
-
: I don’t believe hooks have a proper shell session/environment, so we use the absolute path to the
systemctlbinary. <<