Keeping it running is a job of its own.
Building it once was the easy part. This is what took three years: how a change gets shipped, how I find out something's wrong before a client does, and what I still do by hand.
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.
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:
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 wayThe 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.
Watching it run
For years, monitoring meant opening dashboards and hoping. Now it's split by the question each tool answers:
| Signal | Answers | Tooling |
|---|---|---|
| Metrics | Host, container, Proxmox, database and app health | Prometheus + exporters |
| Logs | What happened on a host or service | Grafana Alloy + Loki |
| Availability | Whether important endpoints answer, seen from outside | Uptime Kuma |
| Dashboards | Querying and visualising metrics and logs | Grafana |
| Alerting | Telling me before a client notices | Alertmanager + Telegram, Uptime Kuma + Telegram |
Five components, each with one job:
| Component | Role |
|---|---|
| Prometheus | Scrapes metrics and evaluates alert rules |
| Loki | Stores the logs pushed by Grafana Alloy |
| Alertmanager | Routes Prometheus alerts to Telegram |
| Grafana | Dashboards over Prometheus and Loki |
| Uptime Kuma | Active 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.
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.
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.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication noThe 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.
$ 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 3Rebooting 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.
# 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: YesBringing 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:
Service defined → VLAN chosen → address and DNS assigned
→ proxy/firewall path reviewed → observability added
→ persistence risk noted → documentation updatedThe 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 type | Default placement |
|---|---|
| Internal app or tool | Internal |
| Database or object storage | Data |
| Build worker or CI automation | CI |
| Internet-facing ingress | Public, usually only as a proxy entry point |
| Admin or control-plane UI | Mgmt, 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.
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.
$ pvecm status
Quorum information
------------------
Nodes: 3
Quorate: Yes
Membership information
----------------------
Nodeid Votes Name
0x00000001 1 pve01 (local)
0x00000002 1 pve02
0x00000003 1 pve03The runbooks I have today, by area:
| Category | Covers |
|---|---|
| WireGuard | Adding a remote-access peer, adding a tunnel |
| Hosts & cluster | Base machine setup, Proxmox node maintenance, the pve01 GPU driver, disaster recovery |
| Network | DNS zone changes, bringing a new VLAN online |
| Apps | Static site hosting |
What's still manual
Two things are still done by hand, or not done at all:
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.
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.