Push to deploy in forty lines of shell
Every deploy pipeline I have inherited started as three commands someone ran by hand. The pipeline was what happened after those commands were forgotten. So for this site I skipped the middle step and wrote down the three commands.
The whole thing
The server runs a tiny listener. GitHub posts to it, the listener checks the signature, and then it does what I would have done over SSH.
#!/usr/bin/env bash
set -euo pipefail
cd /srv/martinkrizan.com
git fetch --quiet origin master
git reset --hard --quiet origin/master
docker compose build --quiet app
docker compose up -d --no-deps appgit reset --hard rather than git pull, because the server has no business
having local changes, and a merge conflict at 23:00 is not a deploy strategy.
Checking the signature
The one part worth being careful about. GitHub signs the body with the secret you configured; anything that does not match gets a 401 and never reaches the shell.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(body, signature, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Use timingSafeEqual, not ===. It costs nothing and removes a whole class of
argument.
What I gave up
- Parallel jobs. There is one deploy at a time, serialised by a lock file.
- A build matrix. There is one target and it is this server.
- Logs in a web UI. There is
journalctl.
What I got
Deploys take eleven seconds and the pipeline fits on one screen. When it breaks, the stack trace is a shell error on a line I wrote, not a step in a vendor's runner. That trade has been worth it for every project I own end to end, and for none of the projects where somebody else is on call.