Get in touch →
01

Shipping a change

Every push to a repo I deploy goes through the same steps: build, test, package, deploy, verify. Jenkins runs all of it. I didn't pick it after comparing tools; I'd used it before and knew how to run it, and sometimes that's the right reason. Harbor sits next to it as my own image registry, so a build never depends on Docker Hub's rate limits or uptime.

Builds don't run on the Jenkins host. Two agents on the CI VLAN do the work, one on pve01 and one on pve03. They can pull dependencies and push images, and they can't administer anything. The reasoning behind that split is on /system.

What happens after a git push, from the GitHub webhook to the Telegram message.

Anything deployed this way should already have a Prometheus target, an Uptime Kuma monitor and ideally a Grafana dashboard before it ships. That's what the next section is about.

This website ships the same way, through one pipeline that deploys two 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 host
  → health check both projects
  → Telegram message per project, or one on failure
  → agent workspace cleaned either way
A pipeline run in Jenkins: build, image push, deploy, health check.

The decisions behind it:

  • The docs deploy with rsync, not as a container. The docs site is static HTML built with mkdocs --strict, so a broken link fails the build. Packaging it as an image would add a trip through Harbor for nothing: Caddy already serves files directly, so Jenkins builds them and copies them over as a deploy user that can only write to that one directory.
  • The website deploys as an image. It builds from apps/webpage, is pushed to Harbor tagged with the short commit and build number, and redeploys with Docker Compose on the public host.
  • Both projects redeploy on every push. I used to detect which paths had changed. It kept breaking on Jenkins' first builds and replays, in ways that were easy to miss. Both builds are small, so rebuilding everything every time is simpler and safer.
  • Telegram is the only notification channel. One message per deployed project on success, and one message with the failed stage, the commit and a console link on failure. No email, and no dashboard I have to remember to check.
  • The workspace is always cleaned. cleanWs() runs in post { cleanup {} } whatever the result, so a failed run never leaves half-built files for the next one to trip over.
Harbor, the registry this pipeline pushes to.
02

Watching it run

For years, monitoring meant opening dashboards and hoping. Now it's split by the question each tool answers:

Exporters feed Prometheus, Alloy feeds Loki, Grafana reads both, and every alert ends up in the same Telegram chat.
SignalAnswersTooling
MetricsHost, container, Proxmox, database and app healthPrometheus + exporters
LogsWhat happened on a host or serviceGrafana Alloy + Loki
AvailabilityWhether important endpoints answer, seen from outsideUptime Kuma
DashboardsQuerying and visualising metrics and logsGrafana
AlertingTelling me before a client noticesAlertmanager + Telegram, Uptime Kuma + Telegram

Five components, each with one job:

ComponentRole
PrometheusScrapes metrics and evaluates alert rules
LokiStores the 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 every 15 seconds. Targets that change by host or VLAN come from file_sd_configs JSON files, one per zone, so adding a machine means editing a target file instead of the main config. Besides node_exporter, it scrapes cAdvisor on the Docker hosts (with some high-cardinality series dropped before storage so the series count doesn't explode), pve_exporter for the cluster, postgres_exporter for both databases, and the native metrics of Authentik, Zitadel and Grafana.

Grafana Alloy ships logs from every host to Loki, which keeps 30 days on local storage with the TSDB schema. Loki's ruler already points at the same Alertmanager, so log-based alerts will use the same path once I write them. Right now there are none.

Alertmanager and Uptime Kuma both notify through Telegram, but they answer different questions. Alertmanager fires on what the metrics say: high CPU, a target that stopped answering, a database out of connections. Critical alerts wait less and repeat sooner than warnings, and a host's warnings are muted while a critical alert for that host is firing, so one failure doesn't send me five messages. Uptime Kuma ignores metrics completely and only asks whether an endpoint responds. I want both: one catches slow internal degradation, the other catches "it's simply down" even when every internal metric looks fine.

One of the Grafana dashboards I check every day.
An Alertmanager notification arriving on Telegram.

A few rules keep the stack from sprawling as it grows:

  • Every new host gets node_exporter and Grafana Alloy. No exceptions. That's the baseline before anything else is installed.
  • cAdvisor only where Docker runs. No point scraping container metrics on a host without containers.
  • Uptime Kuma only where reachability matters to someone. Not every internal service needs an outside check, only the ones people or clients actually use.
  • An alert exists only if someone should act on it. An alert with no action behind it is noise, and noise gets muted a week later.
  • Firewall rules follow the scrape paths. Metrics don't get a shortcut. Prometheus reaching a database exporter is still an explicit, narrow rule.
The alert rule groups for apps, infrastructure and PostgreSQL, each limited to things someone would act on.
Uptime Kuma's status view, the outside-in check.
03

Keeping hosts consistent

Three machines with three different histories (see /system) don't end up with matching habits on their own. Left alone, each host gets different users, different SSH settings and different monitoring depending on when I set it up. I didn't want that to be a variable, so every new VM or LXC goes through the same sequence before it does anything else: a non-root admin user, key-only SSH with root login and passwords switched off, and the observability agents already reporting before I consider it done.

sshd hardening
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no

The last step, node_exporter and Grafana Alloy on every host, is what keeps the monitoring above trustworthy. If a machine isn't reporting metrics one scrape interval after it comes online, I treat it as a setup bug, not something to clean up later. One detail from the runbook worth repeating: test the key login from a second terminal before closing the first, or you can lock yourself out.

Base setup on a fresh VM, typed by hand from the runbook
$ adduser alberto && usermod -aG sudo alberto
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub alberto@<new-host>
$ ssh alberto@<new-host>        # second terminal, before closing the first
$ sudoedit /etc/ssh/sshd_config && sudo systemctl restart sshd
$ sudo systemctl enable --now node_exporter alloy
$ curl -s localhost:9100/metrics | head -n 3

Rebooting a Proxmox node for a kernel update or hardware work follows the same discipline in reverse. First I drain it: live-migrate the VMs that can move, shut down deliberately whatever can't, and check that nothing is left running. Then I reboot and watch it rejoin. One node at a time, always, because the cluster needs two of three nodes healthy to keep quorum, and the next one doesn't start until pvecm status shows the previous one fully back. pve01 has one extra catch: its kernel is pinned, because a new kernel would boot without the NVIDIA driver the GPU needs.

Draining pve02 before a reboot (illustrative)
# on pve02
$ qm list                          # VMs still running here
$ qm migrate 105 pve03 --online
$ pct list                         # containers
$ pct shutdown 210                 # local storage, stop it cleanly
$ qm list && pct list              # nothing running: pve02 is clear
$ reboot
...
$ pvecm status | grep Quorate
Quorate:          Yes
04

Bringing something new online

Every service that joins the platform goes through the same checklist, so nothing drifts away from the network, proxy and monitoring 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 matter are which VLAN it lands in and which of the three proxies fronts it. Both follow from what the service is:

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

Everything after that is mechanical: the narrowest firewall rule that matches the real path (never a broad VLAN-to-VLAN allow), the host firewall, the observability agents, an Uptime Kuma monitor if people use it, and, if it stores anything, a written note that its data isn't backed up yet.

A newly added service showing up as healthy Prometheus targets, the last step of the checklist.
05

When something breaks

My documentation has two kinds of pages, and I keep them apart on purpose. A runbook is exact steps for something where the commands don't depend on judgement: restart this, migrate that. A guide is a way to make a structural decision where the right answer depends on context, like how to isolate a new client. If you know what has to happen, you want a runbook. If you're deciding what should happen, you want a guide.

Disaster recovery is written as a process, not a script, because there's no tested off-node restore yet. Recovery depends on what still exists on the surviving hosts and on what's documented. In order: work out what failed (a service, a VM, a whole node, a disk or the network); check whether the cluster still has quorum and whether DNS, routing and proxies still work; rebuild what can be rebuilt using the docs as the source of truth; and validate it like a fresh deployment, so DNS resolves, the proxy path works, the Prometheus target comes back and Uptime Kuma turns green. Whatever the incident showed was missing, like a config path nobody wrote down, goes into the docs afterwards so the next one is a little less improvised.

The recovery process in five steps, and the gap it can't cover yet.
pvecm status during an 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 I have today, by area:

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

What's still manual

Two things are still done by hand, or not done at all:

Known gap

There are no backups running yet. In practice, if a node with local-only storage dies, or I make a mistake on the wrong bucket or database, I can only recover what still exists on another host. It's the same gap the storage section on /system points out, seen from the operations side.

The second is automation, or the lack of it. The base machine setup from earlier 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 tool enforces it, and that's a much weaker guarantee.

A smaller one: pve03 doesn't have Wake-on-LAN configured yet. The reason is about as mundane as it gets: I haven't connected a monitor to change the BIOS setting. Since it has an iDRAC for remote power anyway, it hasn't been urgent.

Both of the big ones are on the list. The full backlog (backup target, restore tests, SSO and secrets rollout, infrastructure as code) is on /roadmap.

07

What I'd do differently

Three years in, two things are clear: one I wish I'd done sooner, and one I'd do again without thinking twice.

  • Ansible and OpenTofu should have come on day one. When this was two machines, adopting infrastructure as code would have cost almost nothing. With today's number of hosts and services, adding it afterwards is real, constant friction, enough that I keep putting it off. That's exactly the automation gap above. Starting early would have kept every host's configuration in sync without touching each one by hand.
  • Splitting the network was worth it. Eight VLANs and their firewall rules make every change more work than a flat network would. I'd still make the same call: the isolation is worth the friction of maintaining it.

Next: /system covers what exists (the hardware, the network, and the services this pipeline deploys and this stack watches), /security covers how access is controlled, and /roadmap is the backlog, in order.