Get in touch →
01

Shipping a change

Every push to a repo I track goes through the same shape of pipeline, whatever the project: build, test, package, deploy, verify. Jenkins is what actually runs all of it, and if I'm honest about how it got picked, it wasn't the result of a tooling evaluation — I'd used it before, I knew how to run it, and I never went shopping for alternatives. Sometimes the boring, already-known tool is the right call precisely because it's already known. Harbor sits next to it as the image registry, my own, so a built container never has to touch someone else's Docker Hub rate limit or uptime.

Builds don't run on Jenkins' own host — two dedicated agents do the actual work, sitting in their own network segment with outbound access to pull dependencies and push images, and nothing more. Being able to reach out is a completely different permission than being trusted to reach in and administer anything; the full reasoning behind that split lives on /system.

The general shape every deployment follows: commit, build, package, deploy, verify.

Any service deployed this way is expected to already have a Prometheus target, an Uptime Kuma monitor, and ideally a Grafana dashboard before it ships — that expectation is what the next section is actually about.

This site — the one serving the page you're reading — is a good concrete example, because one pipeline deploys two genuinely different kinds of artifact from the same monorepo.

This repo's pipeline
git push → GitHub webhook → Jenkins
  → docs: mkdocs build --strict → rsync to the internal proxy
  → webpage: Docker build → Harbor push → Compose deploy on the public-facing host
  → health check both projects
  → notify Telegram per project, or once on pipeline failure
  → agent workspace cleaned either way
A pipeline run in Jenkins — build, image push, deploy, health check.

A few design choices behind it, worth knowing rather than just assuming:

  • Docs deploy by rsync, not by container. The docs site is static HTML; packaging it as an image just to redeploy through Harbor would add a registry round-trip for nothing. The proxy already serves files directly, so Jenkins only needs to build and copy.
  • The webpage deploys as an image. It builds from apps/webpage, publishes to Harbor tagged by short commit and build number, and redeploys via Compose on the public-facing host.
  • Both projects redeploy on every push, path detection or not. Path-detection logic kept breaking in first-build and replay scenarios in Jenkins, in ways that were easy to miss. Both builds are small enough that rebuilding everything every time is simpler and safer than being clever about it.
  • Telegram is the entire notification path. One success message per deployed project, one failure message with the failed stage, the commit, and a console link if the pipeline breaks. No email, no dashboard I have to remember to check.
  • The build workspace is always cleaned, success or failure. cleanWs() runs unconditionally in post { cleanup {} }, so a failed run never leaves half-built artifacts for the next one to trip over.
Harbor — the image registry this pipeline pushes to.
02

Watching it run

Observability is split by what question it answers, not bundled into one tool trying to do everything.

How the pieces below actually talk to each other — exporters to Prometheus, Prometheus to Alertmanager, both into Grafana.
SignalAnswersTooling
MetricsHost, container, Proxmox, database, and app healthPrometheus + exporters
LogsWhat actually happened on a host or serviceGrafana Alloy + Loki
AvailabilityWhether important endpoints respond, from the outside inUptime Kuma
DashboardsQuerying and visualizing the two signals aboveGrafana
AlertingGetting told before a client noticesAlertmanager + Telegram, Uptime Kuma + Telegram

The stack behind that table splits into five pieces, each doing exactly one job:

ComponentRole
PrometheusScrapes metrics, evaluates alert rules
LokiStores logs pushed by Grafana Alloy
AlertmanagerRoutes Prometheus alerts to Telegram
GrafanaDashboards over Prometheus and Loki
Uptime KumaActive checks, independent of the metrics pipeline

Prometheus scrapes on a 15-second interval, and targets that vary by host or segment come from file_sd_configs JSON files rather than the main config — adding a machine is usually editing one target file, not rewriting Prometheus itself.

Grafana Alloy ships logs from every host that runs it to Loki, kept for 30 days on local filesystem storage. Loki's ruler already points at the same Alertmanager metrics alerting uses, so log-based alerting can reuse that exact path whenever those rules actually get written — right now, none exist yet.

Alertmanager and Uptime Kuma both notify through Telegram, but they're answering different questions. Alertmanager fires on what the metrics say is wrong — high CPU, a target gone silent, a database with no free connections — with critical alerts repeating faster than warnings, and a warning for a host suppressed while a critical alert for that same host is already firing. Uptime Kuma doesn't look at metrics at all; it's an outside-in check, does this endpoint actually respond. The overlap is deliberate: one catches internal degradation the other can't see, the other catches "simply unreachable" even when every internal metric still looks fine.

One of the Grafana dashboards I actually check daily.
What an Alertmanager notification looks like landing on Telegram.

A handful of rules keep the whole stack from sprawling as it grows:

  • Every new host gets node_exporter and Grafana Alloy. No exceptions — that's the baseline before anything else gets installed on it.
  • cAdvisor only where Docker actually runs. No point scraping container metrics on a host that has none.
  • Uptime Kuma only where user-facing reachability matters. Not every internal service needs an outside-in check — just the ones people, or clients, actually hit.
  • Alert rules exist only for conditions someone should act on. No alert without an action behind it, or it's just noise that gets muted a week later.
  • Firewall rules follow the scrape paths, not the other way around. Metrics don't get a bypass for being metrics — Prometheus reaching a database exporter is still an explicit, narrow rule.
The actual rule groups behind that last principle — apps, infra, and Postgres, each scoped to what someone would actually act on.
Uptime Kuma's status view — the outside-in check.
03

Keeping hosts consistent

Three machines with three completely different hardware histories (see /system) never got a matching set of software habits for free — left alone, that turns into three inconsistent operating environments, with different users, different SSH settings, and different monitoring coverage depending on who set each one up and when. I didn't want "who set it up" to be a variable, so the fix is a fixed bootstrap sequence that runs before a new VM or LXC does anything else: a non-root admin user, key-only SSH with password auth and root login both switched off, and the standard observability agents already reporting before the machine counts as "done".

sshd hardening
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no

That last part — node_exporter and Grafana Alloy on every single host — is what keeps the observability section above honest. A machine that isn't reporting metrics within one scrape interval of coming online is treated as a setup bug, not a later cleanup task.

Bootstrap running against a fresh VM (illustrative)
$ ./bootstrap-host.sh vm-207
==> creating admin user, disabling root SSH login
==> disabling password authentication
==> installing node_exporter, grafana-alloy
==> registering Prometheus target: node-internal
[ok] vm-207 reporting metrics — 1 scrape interval elapsed

Rebooting a Proxmox node for a kernel update or hardware work follows the same discipline in the other direction: drain it first — live-migrate what can move, stop deliberately what can't — confirm nothing's left running locally, then reboot and watch it rejoin the cluster. Nodes go one at a time, never more, because the cluster needs at least two of the three healthy to keep quorum; the next one doesn't start until pvecm status shows the previous one fully back.

Draining pve02 before a reboot (illustrative)
$ qm list --node pve02        # live-migrate anything running
$ pct list --node pve02       # stop what can't move
$ qm list --node pve02 && pct list --node pve02
(empty — pve02 is clear)
$ reboot
...
$ pvecm status | grep Quorate
Quorate: Yes
04

Bringing something new online

Every service that joins this infrastructure goes through the same checklist, specifically so nothing quietly drifts from the network, proxy, and observability model described on /system:

Onboarding a new service
Service defined → VLAN chosen → address and DNS assigned
  → proxy/firewall path reviewed → observability added
  → persistence risk noted → documentation updated

The two decisions that actually matter are which segment it lands in and which of the three proxies fronts it — both follow directly from what the service is, not from convenience:

Service typeDefault placement
Internal app or toolInternal
Database or object storageData
Build worker or CI automationCI
Internet-facing ingressPublic, usually only as a proxy entrypoint
Admin or control-plane UIMgmt, behind the management proxy

Everything after that placement follows mechanically: the narrowest firewall rule that matches the actual path, never a broad segment-to-segment allow; the standard observability agents; an Uptime Kuma monitor if it's user-facing; and, if it stores anything, an explicit note that the data isn't backed up yet — pretending otherwise would undercut the point of writing any of this down honestly.

A freshly onboarded service showing up as a healthy Prometheus scrape pool — the checklist's last step, made visible.
05

When something breaks

Documentation here splits into two kinds, and the distinction matters enough that I keep it explicit: a runbook is exact, repeatable steps for something where the commands don't change based on judgment — restart this, migrate that. A guide is the opposite, a decision framework for a structural choice where the right answer depends on context. If you already know what needs to happen, you want a runbook; if you're deciding what should happen, you want a guide.

The disaster-recovery process itself is deliberately written as a process rather than a script, because there's no tested off-node restore to fall back on yet — recovery depends on what still exists on surviving hosts and what's actually documented. In order: figure out what actually failed — one service, one VM, a whole node, storage, or the network itself — check whether the Proxmox cluster still has quorum and whether DNS, routing, and proxies are still resolving, rebuild whatever's recoverable using the docs as the source of truth (service locations, address allocations, firewall intent), and validate the result the same way a fresh deployment gets validated: DNS resolves, the proxy path works, the Prometheus target comes back, Uptime Kuma goes green. Whatever the incident exposed as missing — a config path nobody wrote down, a dependency nobody mapped — gets added to the docs afterward, so the next one is a little less improvised.

pvecm status — mid-incident (illustrative)
$ pvecm status
Quorum information
------------------
Nodes:            3
Quorate:          Yes

Membership information
----------------------
    Nodeid      Votes Name
0x00000001          1 pve01 (local)
0x00000002          1 pve02
0x00000003          1 pve03

The runbooks that exist today, grouped by what they cover:

CategoryCovers
WireGuardAdding a remote-access peer, adding a tunnel
Hosts & clusterBase machine setup, Proxmox node maintenance, disaster recovery
NetworkDNS zone changes, bringing a new segment online
AppsStatic site hosting
06

What's still manual

Not everything above is as tight as it sounds. Two gaps are worth stating plainly rather than smoothing over, since that's the whole point of this page being honest instead of aspirational:

Known gap

There is still no operational backup system. In practice: a node dying with local-only storage, or a mistake that touches the wrong bucket or database, is not recoverable beyond whatever happens to still exist on a surviving host. It's the same gap /system flags on the storage section, restated here in operational rather than architectural terms.

The second is automation, or the current lack of it. Base machine setup — the bootstrap sequence covered earlier, under keeping hosts consistent — is still typed by hand on every new host, not run from Ansible or OpenTofu. It's consistent because I follow the same runbook every time, not because a machine enforces it, which is a meaningfully weaker guarantee.

One more small one: one of the three nodes doesn't have Wake-on-LAN properly configured yet. The reason is about as trivial as it gets — nobody's connected a monitor to flip the relevant BIOS setting — and since that node already has out-of-band power management, it hasn't been urgent enough to fix.

Both of the bigger ones are tracked, not ignored — the full, current backlog (backup targets, restore testing, identity and secrets rollout, infrastructure-as-code) lives on /roadmap, filed as an honest punch list rather than a wishlist.

07

What I'd do differently

Three years in, two things stand out clearly enough to say out loud — one I wish I'd done sooner, one I'd make the same call on again without hesitation.

  • Ansible and OpenTofu should have started on day one, not now. Back when this was two machines, adopting infrastructure-as-code would have cost almost nothing. With the current sprawl of hosts and services, retrofitting it is real, ongoing friction — enough that I keep not getting to it, which is exactly the automation gap described above. Doing it early would have kept every host's configuration synced without touching each one by hand.
  • Segmenting the network was worth it, complexity and all. Eight segments and the firewall rules that go with them make day-to-day changes genuinely more work than one flat network would. I'd make the same call again — the isolation it buys is worth the friction of maintaining it.

From here: /system covers what actually exists — the hardware, the network, the services this pipeline deploys and this stack watches. /security covers how access into all of it is controlled. /roadmap is the current backlog, in order.