$ cat platform/README.md
How this site runs
I could have put this site on static hosting, but I wanted it on an enterprise level instead. This page covers both halves of that: how a change gets from a branch on my own GitLab to a live deploy, and how a visitor's request actually gets served once it's there. The build panel further down shows exactly what's deployed right now, and the run panel below it shows the latest trip through the pipeline that put it there.
Architecture
The whole path, from a commit to a served request
The diagram covers both halves at once: a change moving through source control, the pipeline and GitOps on one side, and a visitor's request moving through Cloudflare and the cluster on the other. Each piece gets its own section below.
Version control
It starts on my own GitLab
I run my own Git server, with its own container registry and its own pipeline runners. This site's code lives in one project there, and the Kubernetes manifests that describe how it's deployed live in a second, separate project. Argo CD only ever watches the second one, so it never needs read access to the application source, only to what actually gets deployed.
Nobody, including me, can push straight to the main branch. Push access is off for everyone, only a maintainer can merge, force pushes are rejected, and a merge is blocked until its own pipeline has passed. Once a merge request lands, its source branch is removed automatically. Every change goes out on its own branch and comes in through a merge request instead, so there's always a record of what changed and why before it ever reaches production.
The commits and merge requests themselves come from Claude Code, an AI coding agent I run against this same GitLab. In this site's repository it works only on its own branches, opens each merge request itself, and merges once that request's own pipeline has passed. Like me, it can't push straight to main there. I decide what gets built and check the results. How I work with AI covers the rest of that split.
Pipeline
What runs before anything can merge
A merge request can't be merged until its own pipeline passes, so problems surface while I'm still reviewing the change, not after. It builds and scans the container image too, but it deploys nothing. The deploy stages only run on the pipeline triggered by a push to main, so opening a merge request proves a change is safe without ever touching a live environment.
Secret scan (gitleaks)
I run gitleaks against the whole repository, checking for anything that looks like a committed secret. Any finding fails the pipeline outright.
Filesystem & config scan (Trivy)
Trivy checks the raw files and config for known vulnerabilities and misconfigurations before an image even gets built. A high or critical vulnerability with a fix available fails the filesystem scan, and any high or critical misconfiguration fails the config scan.
Copy lint
A house script checks every page's visible text, meta tags, alt text and even SVG labels for an em dash, an en dash, or a hyphen used as a dash. That's a hard gate. It also flags a short list of overused AI-writing words, but that one only reports, since even a case-sensitive filter can still flag a proper noun.
HTML check
A house script parses every page and fails it on an unclosed tag, a broken link or anchor, an og tag that doesn't match the page's own title or description, or any inline script, inline style, or inline event handler, because this site's security policy doesn't allow inline code at all.
Nginx config test
I run the exact nginx binary the site ships on against its own config file. Any syntax error fails it before that config ever reaches a server.
Build the image (Kaniko)
I build the container image with Kaniko instead of a Docker daemon, so the runner building it never needs privileged access. If the build fails, the pipeline stops here.
Image scan (Trivy)
I scan the image that was just built. Same rule as the filesystem scan: a high or critical vulnerability with a fix available fails the job, and that image never gets deployed.
Software bill of materials
I generate a full list of every package in the built image. It fails only if the list itself can't be generated.
Deploy
Merging to main is what actually ships it
Once a merge request lands, the same checks above run again on main, and this time the pipeline keeps going instead of stopping after the image scan. The deploy job clones the manifests repository, writes a fresh build record into the target environment, bumps that environment's image tag, and pushes the commit. Argo CD is watching that repository, and its sync policy is automated with self healing and pruning on, so it converges the cluster to match. A webhook on the manifests repository notifies Argo CD directly on every push, so the sync starts right away instead of waiting on Argo's normal polling interval.
Every push to main deploys to a staging environment first, runs a smoke test against it, and only deploys to production once that passes. The smoke test checks that the homepage returns a real 200, that the security headers are actually present, that an unknown path gets a proper custom 404 instead of a generic one, and that every asset the homepage links to actually loads. A request only gets retried when it got no response at all; a real error status has to show up on the first try, because that's a real failure, not a dropped connection.
This gate caught a real bug in its first hour. The staging smoke test got an intermittent 502 during a rolling update: an old pod started shutting down while the ingress was still sending it requests. The pipeline stopped there, before production. I fixed it with a short delay before a pod is told to stop, so the ingress has time to stop routing to it first. Each pod now waits five seconds, inside a fifteen-second shutdown window. The site runs two replicas on a single-node cluster, with a disruption budget that keeps at least one available if a pod gets evicted.
Removing the poll wait and a caching delay on the build record together cut the time from merge to a verified live site from around nine minutes and thirty six seconds down to somewhere around five minutes. The panel further down this page shows exactly how the most recent run did.
Security controls
Defense in a few different layers
The pipeline above catches most of this before anything deploys. What's left is what protects the site once it's actually running.
No inbound port for this site
This site's Cloudflare Tunnel only dials out, so it doesn't need a port-forward, and the site's own DNS doesn't have my home IP in it. The tunnel is the only way it reaches the internet.
Edge protection first
Cloudflare enforces TLS with a modern minimum version, and a custom WAF rule blocks
the paths scanners usually go for, like .php, /wp-,
/.env and /.git. Cloudflare's DDoS protection sits in
front of that, and all of it happens before traffic ever gets to the cluster.
Least-privilege workloads
The site pods run as non-root with a read-only root filesystem. NetworkPolicy rules limit what each workload is allowed to talk to.
Secrets never touch Git
Credentials live outside the repo, as file-based secrets on the host that uses them. The pipeline and manifests only refer to them by name, never by value.
Scanned after deploy, too
Trivy Operator rescans every image running in the cluster at least once a day, so a CVE that comes out after an image ships still shows up. Prometheus alerts on any HIGH or CRITICAL finding in the site's image, and it also alerts if the image has no scan data at all. That way a missing scan doesn't look like a clean one.
Observability
Watching the whole stack
Metrics & alert rules
Prometheus has alert rules for the site, the tunnel, origin errors and image CVEs. There's no paging or on-call receiver set up yet. Alerts show up in Alertmanager and in a digest that gets read at the start of every working session.
Dashboards
Grafana puts the metrics and logs together in one view of how the cluster and the app are doing.
Logs
Loki and Alloy collect and index the logs from every pod. When something breaks, that's the trail I follow.
Security sentinel
Rule-based, and off by default
The sentinel is a Kubernetes CronJob that reads the nginx access logs out of Loki and scores each client against a handful of fixed rules, which look at probe paths, a surge of 4xx errors and the request rate. There's no model making the call. When a client crosses the threshold, it can create a Cloudflare IP block that expires on its own after a set TTL, with a cap of 10 blocks per run. It also fails closed, so it won't create a block it can't undo later. It ships suspended and in dry-run, and it only starts taking real action once it's switched on by hand with a scoped API token.
Live
What's deployed right now, and how it got there
Deployed build
This comes straight from /build-info.json, which gets refreshed on every
deploy. If that file can't be reached, the panel says so instead of showing old
data.
- Commit
- …
- Short SHA
- …
- Pipeline ID
- …
- Built at
- …
- Image
- …
Vulnerability scan (Trivy)
Latest pipeline run
After production is deployed and verified, the last job in the pipeline builds a public, unauthenticated report of that entire run and publishes it the same way as the deploy itself, through the manifests repository. It runs last on purpose, after verification, so the "merge to live" number below only counts a deploy that passed its checks. Before it writes anything, it scans its own output for internal hostnames or addresses and refuses to publish if it finds one, so an unauthenticated report can go out safely with no login in front of it. That report also counts how many of the latest change's commits came from the AI agent, pulled straight from the merge request's own commits, and the panel below shows it.
The report always lists its own job as "running" because the file gets written to disk before that job is marked done. The panel below shows that job as the one that wrote this report, not as something stuck.
Merge request data loads with JavaScript.
Pipeline: unavailable
unavailable
Stages
Stage data loads with JavaScript, or falls back to this line if the report can't be reached.
Checks from this run
Check counts load with JavaScript.
Image
unavailable
Staging
unavailable
Production
unavailable
Merge to live time loads with JavaScript.