CI/CD pipeline for this site — GitHub Actions, GHCR and a self-hosted runner
🟢 This one isn't a case study — you're looking at its output right now. Every page on opswithzaeem.com is served by a container this pipeline built, versioned, and shipped. Built and debugged in production on Aug 30, 2026.
What it does
One command releases the entire platform:
git tag v1.0.6 && git push origin v1.0.6GitHub Actions picks up the tag, dispatches the build to a self-hosted runner (my own machine, registered as the repo's build agent), builds six Docker images — the site plus five tool-category images kept release-ready for the Kubernetes migration — and pushes them all to GitHub Container Registry under an immutable version. Deploying is a one-line change naming which version production runs; rolling back is the same line with the previous number, live in seconds.
Architecture
Diagram source
flowchart TD
DEV["git push"] --> CI["GitHub Actions — hosted runner\n348 tests · lint · type-check\non EVERY push"]
TAG["git tag vX.Y.Z"] --> GHA["GitHub Actions — release workflow"]
GHA --> PROBE{"version already\nin registry?"}
PROBE -->|yes| SKIP["skip — versions are immutable,\nnever rebuilt"]
PROBE -->|no| RUNNER["SELF-HOSTED RUNNER\nwarm Docker layer cache\nbuilds 6 images"]
RUNNER --> GHCR[("GHCR\nvX.Y.Z + sha-commit\nper image")]
GHCR --> PROD["production host\npulls the pinned version\ndocker compose --no-build"]
VISITOR["visitor"] --> CF["Cloudflare edge"] --> TUNNEL["outbound-only tunnel\nzero open ports on the origin"] --> PROD
style RUNNER fill:#8b5cf6,color:#fff
style GHCR fill:#22c55e,color:#fff
style PROD fill:#f59e0b,color:#fffThe engineering decisions, and why
- Immutable versions, no latest. Every release gets vX.Y.Z plus a sha-<commit> receipt; a version is never rebuilt or re-pushed. latest is deliberately absent — deploys name an exact version, so the deploy command itself is the audit trail, and "what is running in production?" is answered by one line of config plus docker inspect reading the commit off the container's OCI labels.
- A version names the set, not an image. All six images build from the same commit at the same version in one run, so image-to-image version drift is impossible by construction. The workflow probes the registry first and skips versions that already exist — the local release script and CI produce interchangeable, collision-free output.
- Zero secrets in CI. The site pre-renders 189 pages of Notion-backed content at build time — but CI holds no Notion token at all. Content is recorded once into a snapshot image in GHCR; builds replay it with zero API calls, and a hole in the snapshot fails the build loudly instead of shipping thin. Auth to the registry is GitHub's per-run GITHUB_TOKEN, minted and destroyed per run — no long-lived credential exists anywhere in the pipeline.
- The registry is the interface. Everything upstream of GHCR (who builds: laptop → hosted runner → self-hosted runner, three swaps in one day) and downstream (who runs it: laptop today, VPS or Kubernetes next) changes independently, because both sides only agree on a versioned image name. The app never noticed any of it.
- Zero open ports. The origin dials out to Cloudflare through a tunnel; there is no inbound path to the machine at all. TLS, caching and Always-Online failover live at the edge.
- Self-hosted runner economics. GitHub's hosted runners are fresh 2-vCPU VMs — a cold release took 40+ minutes. The self-hosted runner keeps a warm Docker layer cache between jobs and does the full six-image set in under 15 minutes, at zero cost. CI checks stay on hosted runners so every push is tested even when the build machine is offline.
Debugged in production, same day
Three real incidents, found and fixed during bring-up — the part of CI/CD no tutorial covers:
- A swallowed rate limit became silent data corruption. A Notion 429 during content recording was caught by a graceful-degradation handler and baked seven "content unavailable" pages into three consecutive releases — invisible, because 105 of 112 pages survived the count-based guard. Fix: error handling now distinguishes setup errors (degrade with a notice) from transient errors (throw — fail the build, leave the cache empty so the next request retries). The recorder now heals itself: a rate-limited attempt fails loudly and the resumable retry fills the gap.
- DOCKER_CONFIG isolation hid more than credentials. The workflow isolates Docker's config per job so CI's ephemeral token can't overwrite the machine's real registry login — which also removed buildx, because Docker discovers CLI plugins in $DOCKER_CONFIG/cli-plugins. Fix: symlink the plugin into the isolated config — the job gets buildx without getting the credentials.
- "Works in my terminal, fails as a service" — the defining self-hosted-runner failure class: macOS folder privacy blocking a background service (exit 126), and Docker Desktop's socket living at ~/.docker/run/docker.sock rather than /var/run. Machine-specific config went into the runner's own .env, keeping the workflow file portable to the future Linux VPS runner.
Results
- Release: one git tag, ~15 minutes, unattended, six images, tested on every push beforehand
- Deploy: one config line + one command; rollback in seconds to any previous version
- Security: no registry PAT in CI, no CMS token in CI, no open ports on the origin, non-root containers
- 40+ min → under 15 min per release by owning the build machine
- Full runbooks in the repo: registry & versioning guide, from-scratch production setup, phased roadmap to VPS and Kubernetes — written so any engineer could operate it
GitHub Actions Docker buildx GHCR self-hosted runner Cloudflare Tunnel Next.js semantic versioning Notion API
How the site itself is built
The site is a Next.js application with Notion as its CMS — I write every learning note and page in Notion, and the site turns them into fast static pages. The clever part is when the work happens:
- At build time, the pipeline pre-renders every page (about 190 of them) into plain HTML inside the Docker image. Visitors never wait for the Notion API — they get pages that were finished before the container even started.
- When I edit a page in Notion, a webhook tells the site within seconds, and only that page is re-rendered and swapped in. No rebuild, no release, no downtime — writing content and shipping code are completely separate lanes.
- Rendered pages live on a Docker volume, so they survive restarts and redeploys. A page is rendered once, ever.
- Content for CI builds comes from a recorded snapshot image in the registry, so the build machine needs no Notion credentials and makes zero API calls.
Diagram source
flowchart LR
N["Notion\n(I write here)"] -->|webhook, seconds| S["Site container\nre-renders ONE page"]
N -->|recorded once| SNAP["content snapshot\nimage in GHCR"]
SNAP -->|zero API calls| B["release build\npre-renders every page"]
B --> S
V["visitor"] --> CF["Cloudflare"] --> SThe free tools — how every one of them works
The site ships 38 free tools for systems and DevOps engineers. The design rule behind all of them: everything computes in your browser — what you paste never leaves the page (the only two exceptions, DNS Lookup and My IP, need a server by nature and say so right on the page). Each tool exists because of a real task from real ops work.
🌐 Networking
- Subnet / VLSM Calculator — you give it a network in CIDR form (like 10.0.0.0/16) and it does the binary math to show exactly which addresses it covers, and how to slice it into smaller subnets. Every firewall rule and VPC plan starts with this arithmetic.
- My IP — shows the public address the internet sees for you, which is usually not what your machine thinks it has, because your router's NAT sits in between. Used whenever you allow-list yourself on a server.
- DNS Lookup — asks the DNS system "what does this name point to?" for any record type. Half of all outage investigations start with "does the name even resolve?" — this answers it, and shows mail (MX) and verification (TXT) records before a cutover.
- IP & Domain Owner Lookup — before you block or report an address, two questions matter: is it a real public address, and whose network is it? The tool first recognizes special ranges (private, loopback, carrier-grade NAT) and explains them; real public addresses get a registry lookup.
- MAC Lookup — the first three bytes of any MAC address are a vendor code. Paste an address from a DHCP table and the tool tells you who made the device — the fastest way to name unknown hardware on your network.
🔐 Security & Keys
- CHMOD Calculator — translates Unix file permissions between the number form (644) and the letter form (rw-r--r--) both ways, with checkboxes so you can see which permission each digit grants. SSH flatly refuses a private key with the wrong mode — this is how you know the right one.
- Password Generator — builds strong random secrets in your browser using the cryptographic randomness API, with length and character controls. Nothing is sent or stored anywhere.
- Hash Generator — computes checksums (SHA-256 and friends) of any text you paste. A hash is a fingerprint: if two files produce the same hash, they are byte-for-byte identical — how you verify a downloaded ISO or compare configs across servers.
- SSL Certificate Toolkit — a guided, numbered walkthrough of the whole certificate procedure: generate the CSR, check the issued certificate, confirm the key matches, get the chain order right. Each step catches its classic mistake before it becomes an outage.
- Scam Link Checker — reads a suspicious link's text without ever opening it, and points out the tricks scammers rely on: look-alike letters (paypa1), a real brand parked in front of an unrelated domain, characters from other alphabets, credentials smuggled before an @.
- RSA Key Generator — creates a public/private key pair in the browser for demos and test environments, no openssl needed. The page itself tells you production keys should be minted on the machine that keeps them.
☁️ DevOps & Cloud
- docker run → Compose — paste a long docker run command from some README and get the equivalent docker-compose.yml. The compose file is reviewable and lives in git; the one-liner gets lost in shell history.
- Kubernetes Resource Calculator — does the scheduler's arithmetic: pods are placed by requests (never limits), and a node's usable capacity is what's left after the OS and kubelet take their share. Enter your pod sizes and node types; it tells you pods per node and nodes needed.
- systemd Unit Generator — builds a service unit file with the mistakes pre-fixed: Restart= so the service doesn't die at 3 a.m. and stay dead, a non-root user, absolute paths that work at boot.
- nginx Reverse-Proxy Generator — generates the ten lines of nginx that sit in front of almost every web app, with the pitfalls handled: the proxy headers (so your app doesn't log every visitor as 127.0.0.1), the websocket upgrade, the HTTP→HTTPS redirect.
- Log-Line Parser Playground — write a parsing pattern and watch it run against your pasted log line on every keystroke, instead of testing blind in production. Presets for nginx logs, IPs, timestamps.
- SLA & Error Budget — turns "99.9% uptime" into what it really permits: 8.8 hours of downtime a year. Enter a target, get the error budget per day/month/year — the number on-call planning actually uses.
- Cron Parser — reads a cron expression (0 3 * * 1) back to you in plain English and shows the next run times, so a misread field doesn't fire your backup at the wrong hour.
- YAML ⇄ JSON — converts both ways. Kubernetes speaks YAML, APIs speak JSON, and this is also the quickest way to find the indentation error a YAML parser is complaining about.
- Regex Tester & Guide — regular expressions are write-only until you can watch them match. Type a pattern, paste your real log lines, and see the matches highlight live — with a guide of recipes from real ops work.
- Text Diff — compares two texts side by side the way Beyond Compare does: changed lines aligned row against row, hatched gaps where one side has extra lines, the exact changed words darkened within each line, and arrows to copy a difference from one side to the other. Both panes stay editable the whole time.
- Galera Health Check — paste the raw output of one MySQL status query and it reads the nine variables that matter for a Galera database cluster (quorum, sync state, flow control) and gives a verdict with the exact next command for each finding.
- HAProxy Config Linter — catches the load-balancer config mistakes that pass human review and then cause outages: a use_backend pointing at a backend that doesn't exist, servers without health checks that keep receiving traffic after they die. With line numbers, before you reload.
- Load Test Sizer (Gatling) — answers the first question of every load test: "how many virtual users produce my target requests per second?" It applies Little's Law (users = rate × time-in-system) and hands you a paste-ready Gatling setup.
- K8s YAML Maker — pick one of 13 Kubernetes resource kinds, fill in a form, and read the manifest being written live — including the scheduling parts nobody remembers by heart: nodeAffinity expressions, anti-affinity spreads, tolerations, topology constraints, probes and security contexts, in the exact key order kubectl uses.
🧰 Utilities
- Number Base Converter — converts between binary, octal, decimal and hex (permissions are octal, netmasks are hex, flags are binary), auto-detecting prefixes like 0x, and lays out a byte's bits with their values so you can see why the number is what it is.
- Unix Timestamp Converter — logs and JWTs record time as a big number of seconds (or milliseconds — the ambiguity is the trap). Converts both directions for correlating log lines across systems.
- UUID & ULID Generator — generates the collision-proof IDs distributed systems need. It explains the practical difference: v4 is pure random; v7 and ULID sort by creation time, which keeps database indexes fast.
- URL Encoder & Inspector — URLs can't carry spaces or special characters raw, so they get percent-encoded — and encoding bugs surface as broken callbacks. Encode, decode, and take a URL apart into its pieces.
- Data Size Converter — disks are sold in GB (powers of 1000) but Linux and Kubernetes count in GiB (powers of 1024) — a 7% gap that quietly breaks capacity math. This converts honestly between the two families.
- Base64 Encode / Decode — Base64 turns binary data into safe printable text; it's how Kubernetes stores Secrets. Encode and decode locally.
- QR & Barcode Generator — turns text or a link into a QR code, or a number into a proper Code 128 barcode — using the same set-optimization real barcode printers use, so it scans identically to commercial output.
- JSON Formatter & Viewer — format, minify and validate JSON without the data leaving the page. One missing comma breaks a consumer; this finds it.
- XML Formatter & Viewer — the same for XML, which SOAP services and plenty of telecom provisioning APIs still speak.
- JWT Decoder — a JWT login token is three Base64 parts; this decodes the header and claims locally so you can see why a login fails — expired, wrong audience, wrong issuer — without pasting your token into some random website's server.
- Markdown Editor — write on the left, see the rendered result on the right, then download or copy. READMEs, runbooks and PR descriptions are all Markdown.
- Significant Figures — shows which digits of a number are actually significant and why (1.20 promises more precision than 1.2), then rounds values to the precision you truly measured — the honesty rules of capacity math and engineering reports.
🤖 AI
- Prompt Token Counter & Trimmer — AI models bill per token and cut off at a context limit, but token counts are invisible while you write. This estimates the count live as you type (honestly labelled an estimate — exact numbers need the vendor's own tokenizer) and helps trim a prompt down to budget.
💡 Why this section belongs in a pipeline write-up: these 38 tools are also the five extra Docker images the pipeline builds each release. Each category can run as its own container holding zero secrets — a calculator has no business holding an API token — which is the architecture waiting for the Kubernetes migration, where an Ingress will route each /tools/* path to its own tiny, credential-free deployment.