Lecture 5
01 / 00

Page ↑ / Page ↓ to move · scroll works too

Linux & DevOps — Lecture 5 of 8

Push to Deploy

Lecture 4 ended with a rollout one-liner you type over SSH by hand.
Today: enough Python to write a small program that does the typing for you, the moment you push.

All slides available at
slides.raafat.io

A new language, in service of one goal

Everything downstream of this lecture — git from Lecture 4, services and units from Lecture 3, bash from Lecture 2 — is already something you know. What’s missing is the thing that sits and waits — a small Python process that GitHub can talk to, that decides whether to trust what it hears, and that hands off to the same kind of deploy script you’d otherwise run by hand. We’ll start from nothing — variables, if/for, functions — build the listener, stand up the app it deploys, wire the two together, and only then go flip the switch on GitHub’s side.

python pip & venv flask hmac docker caddy github webhooks
01

Python Basics

If you've never written Python before, this is the whole language you need for today.

  • Running a script vs. the interactive REPL
  • Variables and the types you'll actually use
  • Control flow: if, for, functions — indentation as syntax
  • Dictionaries: what a JSON payload turns into
  • Importing: the standard library ships with Python

Two ways to run Python

~
$ python3
>>> print("hello")
hello
>>> exit()
$ python3 hello.py
hello
python3 alone drops you into the REPL — >>> is Python's prompt, answering back after each line, good for trying one thing at a time. A .py file is what you write once and run the same way every time, which is what today's listener will actually be.

Variables and the types you’ll actually use

~
name = "listener"
port = 9000
enabled = True
tags = ["push", "ping"]
config = {"host": "0.0.0.0", "port": port}
  • str, int, bool — text, numbers, true/false, no type declared up front
  • [ ] — a list, an ordered, changeable sequence
  • { } — a dict, key-value pairs. This is exactly what a JSON object becomes once Python parses it.

Control flow: indentation is the syntax

~
if enabled:
print("listening")
else:
print("off")
for tag in tags:
print(tag)
def greet(who):
return "hello, " + who

No braces, no then/fi like bash — the block that belongs to an if, for, or def is whatever’s indented one level further under its :. Get the indentation wrong and Python refuses to run the script at all, rather than just formatting it oddly.

Dictionaries, because a webhook payload is JSON

~
payload = {"ref": "refs/heads/main", "pusher": {"name": "ada"}}
payload["ref"]
payload.get("ref")
payload.get("branch", "unknown")
payload["pusher"]["name"]
payload["ref"] raises an error if that key is missing; payload.get("ref") returns None instead, and .get("branch", "unknown") supplies a fallback value. When you don't control what's inside a request, .get(...) is almost always the safer read.

Importing: the standard library ships with Python

~
import json
import hashlib
import os
from datetime import datetime
  • import brings a module's names into your script — you call hashlib.sha256(...), not just sha256(...)
  • json, hashlib, os — all standard library, installed the moment python3 is
  • from x import y pulls one name out directly, so you can write datetime.now() instead of datetime.datetime.now()
02

Libraries, pip & venv

  • Why this job needs a library, not just the basics
  • pip: installing what the standard library doesn't have
  • venv: an isolated place for those installs

Why reach for a library here at all

json and hashlib came free with Python and are enough to parse a payload and check a signature. But something first has to hold a network port open, parse raw HTTP requests, and route them to the right function — that's a real, fiddly job, and rewriting it from scratch is a waste of a lecture. Flask is a library that already does it. It isn't part of the standard library, so — unlike json — it has to be installed.

pip: installing what isn’t in the box

~
$ pip install flask
$ pip list
Flask 3.0.0
Werkzeug 3.0.0
pip is Python's dnf — a package manager that fetches from PyPI (the Python Package Index) instead of a distro's repositories. Installing Flask pulls in Werkzeug alongside it, the lower-level piece Flask is actually built on.

venv: don’t install into the system Python

~
$ python3 -m venv /opt/webhook/venv
$ source /opt/webhook/venv/bin/activate
(venv) $ pip install flask
(venv) $ deactivate

A venv is a private copy of Python’s package directory. Without it, pip install writes into the system Python that other tools on the box depend on — one version conflict and you’ve broken something unrelated. Every real deployment gets its own venv.

03

The Webhook Listener

  • Flask: routes and decorators
  • A script that stays running, not one that finishes
  • Reading the request: headers and JSON body
  • Running it as a systemd service — Lecture 3, again

Flask: a library, not a new language

listener.py
from flask import Flask
app = Flask("listener")
@app.route("/webhook", methods=["POST"])
def webhook():
return "ok", 200
app.run(host="0.0.0.0", port=9000)
@app.route(...) is a decorator — it registers the function right below it to run whenever a POST request hits /webhook. Flask handles the socket, the HTTP parsing, and the routing; your job is just to write the function.

A script that waits, instead of one that finishes

Every script on the last few slides did something and exited — top to bottom, then done. app.run() is different: it blocks forever, listening. That’s not a bug, and it’s exactly why this can’t be something you leave attached to a terminal — it needs a service to hold it, the way myapp.service held a process open back in Lecture 4.

What GitHub actually sends

listener.py
from flask import Flask, request
app = Flask("listener")
@app.route("/webhook", methods=["POST"])
def webhook():
event = request.headers.get("X-GitHub-Event")
payload = request.get_json()
print(event, payload.get("ref"))
return "ok", 200
  • X-GitHub-Event — push, ping, and dozens of others
  • request.get_json() — the payload, already parsed into a dict for you
  • Right now: anyone who finds this URL can trigger a deploy

Wiring it into systemd — nothing new here

/etc/systemd/system/webhook.service
[Unit]
Description=GitHub webhook listener
After=network.target
[Service]
ExecStart=/opt/webhook/venv/bin/python /opt/webhook/listener.py
Restart=on-failure
User=deploy
[Install]
WantedBy=multi-user.target

Same three sections as Lecture 4’s myapp.service. ExecStart points at the venv’s own python, not the system one — that’s what actually pulls in Flask.

04

Verifying Signatures

  • Why an unverified webhook is an open door
  • HMAC signatures: proving the request came from GitHub
  • hmac.compare_digest — and why not ==

An unverified webhook is a public “run this on my server” button

The route from the last part runs whatever code follows for any POST request, from anyone, no matter where it came from. Before this touches deploy, the listener has to prove the request was signed by GitHub with a secret only the two of you know — not just check that it looks like the right shape of JSON.

Verifying the signature

listener.py
import hmac, hashlib, os
SECRET = os.environ["WEBHOOK_SECRET"].encode()
def verify(request):
signature = request.headers.get("X-Hub-Signature-256", "")
digest = "sha256=" + hmac.new(
SECRET, request.data, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, digest)

GitHub signs the raw request body with your shared secret and sends the result in X-Hub-Signature-256. You recompute the same HMAC on your end and compare. hmac.compare_digest instead of == — a plain string comparison exits early on the first mismatched character, and an attacker can time that to guess the digest one byte at a time. compare_digest always takes the same time either way.

05

Docker & Caddy

Before the listener can deploy anything, the thing it deploys has to exist: an image to rebuild, and a proxy already pointed at the container it runs in.

  • Image vs. container, in one sentence
  • The docker run flags a compose file boils down to
  • Caddy: a Caddyfile is (usually) three lines
  • Why the app never touches TLS itself

Image vs. container

~
$ docker build -t chess:latest .
$ docker images
REPOSITORY TAG IMAGE ID SIZE
chess latest 4f2a9c1e3b7d 38MB
$ docker run -d --name chess -p 127.0.0.1:8081:80 chess:latest
$ docker ps
CONTAINER ID IMAGE STATUS PORTS
7d1e2a9b0f44 chess:latest Up 2 seconds 127.0.0.1:8081->80/tcp
  • An image is the built, frozen filesystem — code plus everything it needs to run
  • A container is a running process made from that image, the way a process is made from a binary
  • -d detaches, --name gives it a handle docker compose can stop and replace by name next deploy

Why 127.0.0.1:8081, not 0.0.0.0:8081

-p 127.0.0.1:8081:80 binds the container's port only to the host's loopback interface — reachable from the same machine, not from the internet. The app is deliberately not exposed directly. The only thing allowed to reach it from outside is whatever's listening on the public interface: Caddy.

The chess app’s own Dockerfile actually builds on caddy:2-alpine too — a small, container-local Caddy that just serves the built static files on port 80, nothing more. That’s a different job from the host-level Caddy on the next slide, which handles TLS and routes several apps by domain. Two Caddys, two jobs.

The Caddyfile

/etc/caddy/Caddyfile
chess.example.com {
reverse_proxy 127.0.0.1:8081
}

That’s the whole config. reverse_proxy forwards every request for that hostname to the container’s port on loopback. Caddy also requests and renews a Let’s Encrypt certificate for chess.example.com automatically the first time it starts — no separate certbot step, no cron job to remember.

Where each piece actually listens

Public internet

~
$ ss -tlpn
tcp LISTEN 0.0.0.0:443 caddy
tcp LISTEN 0.0.0.0:9000 python (listener.py)

Loopback only

~
$ ss -tlpn
tcp LISTEN 127.0.0.1:8081 docker-proxy (chess)

Two different public ports for two different callers: 443 is where browsers reach the app, through Caddy. 9000 is where GitHub’s servers will reach the webhook listener directly, once it’s wired up — it’s its own small service, not proxied through Caddy, though it could be. From outside the VM, that 9000 shows up as a different number entirely — more on that when we hook this up to GitHub for real, a couple of parts from now.

06

Wiring to deploy

  • subprocess: handing off from Python to bash
  • deploy: git fetch + reset, then docker compose up --build
  • Why the deploy still lives in bash, not Python
  • The full path from push to running container

Handing off to bash

listener.py
import subprocess
@app.route("/webhook", methods=["POST"])
def webhook():
if not verify(request):
return "invalid signature", 403
if request.headers.get("X-GitHub-Event") != "push":
return "ignored", 200
subprocess.Popen(["./deploy"], cwd="/opt/chess")
return "deploying", 202
Popen starts deploy and returns immediately — the listener answers GitHub right away instead of making it wait for a build to finish. Status 202 Accepted means "received, working on it," which is exactly what happened.

deploy: the image and proxy from last part, rebuilt on command

/opt/chess/deploy
#!/bin/bash
set -euo pipefail
git fetch && git reset --hard @{u}
docker compose up -d --build
set -euo pipefail makes the script stop at the first failure instead of plowing on with a half-finished deploy. git reset --hard @{u} instead of git pull — a server checkout should never carry its own local commits, so this throws them away and matches upstream exactly, every time (@{u} is git's shorthand for "the upstream branch this one tracks"). docker compose up -d --build is exactly the docker build / docker run pair from last part, driven from the compose.yml that already names the image and the port mapping — no separate stop/rm/run needed.

Why the deploy step stays bash

Python earned its place for the part that needed a real language and a library behind it: holding a socket open, parsing JSON, computing an HMAC. Rebuilding an image and restarting a container is just a fixed sequence of commands you’d type yourself — exactly what bash from Lecture 2 was already good at. Rewriting that sequence in Python would just be subprocess.run calls pretending not to be a shell script.

The full path from push to running container

git push to GitHub
→
listener.py verify signature
→
deploy fetch, reset, compose up
→
container new code, live

Every piece on this line already exists and works on its own — you can run deploy by hand right now and watch it work. All that’s left is the first box: getting GitHub to actually send that push.

07

GitHub Webhooks

Everything downstream of a push now works. Last step: telling GitHub where to send one.

  • Getting GitHub to your VM: port 9000 → 90XX
  • Configuring a webhook: repo → Settings → Webhooks
  • Payload URL, content type, and the shared secret
  • Picking events: push, not "send me everything"
  • The ping event and delivery history

Getting GitHub to your VM: port 9000 → 90XX

GitHub POST to public-ip:90XX
→
gateway forwards 90XX → 9000
→
your VM listener on 0.0.0.0:9000

Every VM listens on the same port, 9000 — that part of listener.py never changes. What’s shared is one public IP for the whole classroom, so the gateway in front of it forwards a per-VM public port straight to your VM’s 9000: VM 07’s webhook traffic arrives on public port 9007, VM 12’s on 9012. The payload URL you give GitHub uses that public port, not 9000 itself.

What you fill in on GitHub’s side

Payload URL

http://<public-ip>:90XX/webhook — your VM's forwarded port, not 9000 itself and not localhost.

Content type

application/json — otherwise the body arrives form-encoded and request.get_json() comes back empty.

Secret

The same string as WEBHOOK_SECRET on the server. Set once, never sent in the clear again — only its HMAC is.

Events

Just push. "Send me everything" means handling event types your code never checks for.

Saving one round trip: the ping event

The moment you save a webhook, GitHub fires a one-off ping event at it — no push required — so you find out immediately whether the URL and secret line up. After that, every delivery GitHub attempts is logged on the webhook’s page: request, response, status code, and a Redeliver button, which is the fastest way to replay one delivery while you’re debugging the listener instead of making a real commit every time.

08

Lab & Logistics

  • Write a short .py script using dicts, .get(), and a function — no Flask yet
  • Build a Flask listener that verifies X-Hub-Signature-256
  • Stand up the chess app behind Docker & Caddy, reachable over HTTPS
  • Write deploy: git fetch + reset --hard @{u}, then docker compose up -d --build
  • Point a real GitHub webhook at your VM's forwarded port and read the ping delivery
  • Push a commit and watch the container roll over — no SSH required

Fin

All slides available at
slides.raafat.io