MK

martinkrizan.com / blog / push-to-deploy-in-forty-lines-of-shell

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 app

git 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.