Goal
- Write blog post as markdown in a git repo
- Push new posts to a git remote
- Some kind of receive/update hook builds a new website using
hugo
and publishes it on my webspace usingrsync
over SSH
Disclaimer: Security matters!
This post shows a working setup that can run in a trusted network setup. There are a variety of implications using this approach that allow certain attack vectors. Please to not simply copy this without thinking about your needs first.
- Using shared SSH keys without passphrase allows automation, but it will also allow anyone who gets hold of those keys to access your webspace.
- Executing a script from the repo in a post-receive hook on the git remote allows all kinds of supply chain attacks for anyone with access to the repo / remote.
There are possibly many more issues with this setup, so please be considerate in your own approach.
Preliminaries
I am using a RaspberryPi with Raspbian bookworm as base for this setup, but pretty much any Linux system will work as long as the necessary tools are available.
Required:
- git repo with content you want to build and publish
- root access on the remote box, to set up new users and permissions
- server-side software:
podman
,git
,openssh
- webspace with ssh/scp access for publishing the results
How to
Add a remote user and git repo
Set up a new user and basic software on the remote Linux box:
ssh sudoer@example.org
sudo adduser git
sudo apt update && sudo apt install podman git
You’ll use the password for pushing git commits to this remote. Alternatively, you can set up SSH keys.
Switch to the new user and set up git config and a bare git repo:
ssh sudoer@example.org
sudo su git
git config --global init.defaultBranch main
git config --global user.name "Some Name"
git config --global user.email "git@example.org"
cd ~
mkdir -p repos/somerepo.git
cd repos/somerepo.git
git init --bare
Set up access to web space
Then make sure that SSH access to the webspace is available for this new user. All of this needs to be run logged in as git@example.org
:
# generate an SSH key for the git user
ssh-keygen -t ed25519 -C "git@example.org"
# log into remote web space once, checking and accepting the host key
ssh user@webspace-host
logout
# set up authorized key with the webspace host
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@webspace-host
# Try out logging in without having to give a password
ssh user@webspace-host
logout
It’s a good idea to keep backups of this key. I am not using a passphrase because I want the publishing process to be fully automated. But security matters.
Add the new remote to your local working copy
Then, locally, set up the new remote for your local git repo. In my case, I already have a git repo with a remote called origin
where I keep my stuff. This repo contains markdown files that can be converted to a web page using hugo
, a static website generator.
In your local git repo with all your content:
git remote add publish git@example.org:repos/somerepo.git
I called the new remote publish
, but you can choose other names, if you like. Try pushing your main
branch to this remote to see if it works:
git push publish main
You may want to set up exchanged SSH keys here, too. Or, depending on the git client you are using, maybe save the password to a keychain.
Interim result: We now have a second git remote we can push to. Now comes the magic.
Containerized publishing toolchain
I did not want to install all required tools on the remote host, but instead abstract the toolchain from it a bit. Esp. since hugo
is not available as regular Debian package I went for a containerized solution. Alternatives could be nix-shell
or devenv.sh
.
Containerized build and publish script:
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "$0")" && pwd)"
image=my-publishing-image
function docker-bootstrap() {
echo "---------------------------------------"
echo "Creating container with toolchain"
podman build -t $image - <<-EOF
FROM alpine:3.19.0
RUN apk add -U bash hugo rsync openssh
EOF
podman run --rm --network host -v "$root:/workspace" -v "/home/git/.ssh:/ssh:ro" -w /workspace $image "./${0##*/}" foo
}
function do-in-docker() {
echo "---------------------------------------"
echo "Building new version with hugo"
hugo
echo "---------------------------------------"
echo "Publishing new version to webspace"
rsync -rlptv \
--delete \
--rsh='ssh -i /ssh/id_ed25519 -o UserKnownHostsFile=/ssh/known_hosts' \
public/ 'user@webspace-host:/path/to/htdocs/'
}
if [[ "${1:-}" = "" ]]; then
docker-bootstrap
else
do-in-docker
fi
exit 0
This script lives inside my git repo, so I can tweak and maintain it. It does a few things, so let’s take this apart:
- Overall it runs in two stages when called:
- First stage will build a container image based on Alpine with
hugo
,rsync
,openssh
andbash
installed - Then it calls itself inside a container to execute the toolchain sitting on top: run a
hugo
build, then synchronize files to the webspace usingrsync
over SSH with the pre-shared key
- First stage will build a container image based on Alpine with
- Some bits are quite important, as I had to find out running into issues:
- Mapping the
~/.ssh/
folder into the container to have access to the identity file - Explicitly specifying the path to the
UserKnownHostsFile
forrsync
to not stumble on host key verification in the automated process - The script needs to reside in the top-level folder of the repository, because it runs
hugo
which will check for a config file there.
- Mapping the
Make sure this script is set to be executable!
chmod +x build-and-publish.sh
Then commit and push it to the remote.
Add a post-update
hook on the remote
Now we need to set up the second part of the toolchain: What happens when something gets pushed to the publish
remote? The goal is to:
- update a working copy with the latest (just pushed) files
- execute the
build-and-publish.sh
script to process these files
As for the working copy, we’ll simply clone the bare repo locally (as in: on the remote machine):
ssh git@example.org
mkdir publish
cd publish
git clone /home/git/repos/somerepo.git
/home/git/publish/somerepo
is now your (non-bare) working copy used for publishing. This is separate from the bare repo which you use as remote!
We’ll do the second step, the publishing toolchain, using a post-update
hook on the git repo:
ssh git@example.org
cd repos/somerepo/hooks
vim post-update
The file post-update
gets the following content:
#!/usr/bin/bash
set -euo pipefail
cd /home/git/publish/somerepo
env -i git pull -r
exec ./build-and-publish.sh
Make sure that this post-update
script is executable!
chmod +x /home/git/repos/somerepo/hooks/post-update
The post-update
hook will cd
into the working copy, pull the latest files and then invoke the publishing script which does the hugo
build and the rsync
publishing step.
Important note: git hooks come with environment variables pre-set, that’s why we need to use env -i
here to get a clean, new environment. Otherwise the git pull -r
will not work at all.
Putting it all together:
If all pieces have been put together corretly, this is what happens:
- You create a new markdown file in your local working copy
- You push that new file to the
publish
remote - The
post-update
hook pulls the latest files and invokes the publishing script - The
build-and-publish.sh
script runshugo
inside a container to process the new files, then usesrsync
(also inside the container) to push the generated output to your webspace
By using a separate remote, I can choose when to backup my files on a git server (a regular git push
that goes to origin
) or trigger my publishing pipeline (git push publish main
).
Thanks for reading!