Chris Lorenzo

homelab and infrastructure notes · stayz3ro

entry-51

Shipping logs to Loki, journal first

I added Loki and Alloy to the monitoring VM, proved an SSH failure alert, and shipped the journal from n8n and Forgejo. A storage budget, a log sample review, and a container that could not write its own state shaped the rollout.

revisions rev. 1 first set 2026-10-23 last revised 2026-10-23 7 min read

On October 12 I brought Loki and Grafana Alloy up on the monitoring VM. On October 14 I added collectors to the n8n and Forgejo VMs. I wanted a place to query system logs across those hosts and one useful alert from them. I also wanted to know what I was collecting before application output started accumulating on another machine.

The rollout ended with both hosts shipping their journals, Forgejo shipping reviewed container logs, and n8n staying journal-only. Getting there exposed a permissions problem in the collector and a smaller mistake in my validation configuration.

A separate project on the existing VM

I deployed Loki and Alloy as their own Compose project on the monitoring VM. Prometheus, Alertmanager, and Grafana already lived there. The new project joined the existing Docker network, so Loki could reach Alertmanager and Grafana could reach Loki without moving the existing services into a different Compose file.

That gave the logging stack its own startup and rollback commands. It still shared the VM and the host’s failure domain. I accepted that for this rollout: these logs were diagnostic data, and metrics already depended on the same monitoring host.

I pinned the images by digest and ran Loki’s config verification and Alloy’s validator before starting them. Loki came up first, then Alloy. Loki listened on the LAN address, 192.0.2.94:3100; Alloy’s HTTP interface stayed on loopback.

I provisioned the Grafana datasource as code and left Prometheus as the default. Grafana recovered from its restart in about ten seconds. Explore found the journal marker, and the three provisioned dashboards still returned data.

The 12 GB cap needs a gate

The monitoring VM had a 48 GB root filesystem and about 34 GB free at the initial deployment. I set a 12 GB budget for Loki and kept the configured retention at fourteen days, with the compactor enabled and a two-hour deletion delay.

Calling that a cap needs some care. Loki had no size quota. Retention, measured growth, and a free-space alert were the controls. The packet’s growth check was:

daily growth in GB * 14 * 1.25 <= 12

That works out to roughly 0.68 GB a day. It was a budget to check against measurements, not a promise that the fifteenth gigabyte could never be written.

For the LAN rollout I made the disk check an explicit gate before the first collector and again after each host. Root had to have at least 30 percent free, and loki-data had to be below 12,000,000,000 bytes. Either failure meant stop. Shortening retention just to pass the gate was not part of the procedure.

I also added MonitoringRootFreeLow, a warning below 30 percent free for fifteen minutes. The existing disk warning at 15 percent left too little room for this budget. After the final Forgejo change, root was still 71 percent free and Loki data was about 2.7 MB. That proved the rollout fit at that moment; it did not replace the planned growth and retention checks.

One alert I could prove

The first log alert was SSHAuthFailures. It used the journal, so I could prove alerting before collecting application logs.

The rule counted at least eight matching log entries in ten minutes, grouped by host, with a five-minute hold. The patterns covered failed passwords, invalid users, and authentication failures. I left out the earlier pattern for a connection closed by an authenticating user because that also counted harmless probes.

The rule also mapped the host into an instance label. Alertmanager grouped on alert name and instance, so leaving instance absent could combine failures from different hosts into one notification.

I tested it with eight synthetic failed-password records under the fake host phase43-test. I did not cause real failed logins. The alert was pending after a minute and firing after the five-minute hold. Alertmanager showed it active with the configured ntfy and Discord warning receivers. That gave me evidence for the rule and routing before widening collection.

The journal was the first change

The n8n and Forgejo collectors went into separate Compose projects on their respective VMs. Their first configurations collected allowlisted journal units and kernel records. I removed Docker discovery, the Docker log source, and the socket mount from that first deployment.

The containers had read-only filesystems, all Linux capabilities dropped, and explicit mounts for journal access and state. Container logs were a later change, with a sample review and a separate decision about Docker socket access.

For validation I used a dedicated phase43-validation systemd unit. That mattered because the journal configuration had an allowlist. On the original monitoring deployment, ordinary logger messages did not appear; a marker from the allowed unit did.

The initial Loki deployment also rejected backfill older than seven days. That was an ingestion-age limit, separate from the fourteen-day retention setting. I needed to distinguish an expected rejection from a broken collector.

Root could not write its state

The first n8n collector restart-looped with:

mkdir /var/lib/alloy/data: permission denied

The image owned /var/lib/alloy as UID 473 with mode 770. The collector ran as root, but with every capability dropped it could not traverse that parent directory. The writable state mount below it did not solve the parent-directory permission problem.

The monitoring collector had not exposed this because its Compose configuration did not drop capabilities. I had validated a less restricted runtime and then carried the state path into the stricter one.

I stopped the failed collector and changed both the state mount target and Alloy’s storage argument:

- --storage.path=/var/lib/alloy/data
+ --storage.path=/var/lib/alloy-data

I kept the capability drop. The new path avoided the inaccessible image directory without making the container less restricted. After redeployment, the n8n collector ran with a restart count of zero. Forgejo’s collector passed its configuration checks and ran with zero restarts too. The n8n application itself was untouched.

Markers from both hosts reached Loki within twenty seconds, one stream and one entry each. I checked the disk gate again and confirmed Alertmanager was healthy with its warning route unchanged.

Reading a sample changed the scope

I reviewed the application samples in a private terminal before enabling container collection. Forgejo’s output included request routing, usernames, and repository paths. I accepted those logs for authentication visibility and limited discovery to the Forgejo Compose service.

n8n stayed journal-only. Its execution output could carry workflow payloads and credentials. The decision to collect Forgejo output did not make n8n output acceptable automatically.

Enabling Forgejo collection also meant accepting the Docker socket. The bind mount was read-only, but that did not make the Docker API read-only. Socket access was effectively root-equivalent on that VM. Dropping capabilities from Alloy did not remove that trust decision.

Forgejo produced 38 container log entries in Loki within twenty-five seconds. Then the journal marker disappeared.

The generated journal-only config included phase43-validation, but the repository config used for container collection did not. Other allowlisted units still flowed. I had replaced a working validation allowlist with one that dropped the test itself.

I added the unit to both source configurations and redeployed Forgejo. The marker returned. That check caught a real difference between the two configuration paths that a healthy container alone would have missed.

What I would change next time

I would test the collector’s state path under the final capability and filesystem restrictions before treating the pilot as evidence for the remote rollout. I would also put the validation unit into the source configs from the start, so switching collection modes kept the same end-to-end check.

I would keep the sample review before container collection. It gave me two different answers for two hosts, which was useful. Journal-only is the current collection scope for n8n, not an unfinished checkbox.

The LAN collectors are working. The tailnet listener still waits on its separate decision, and cloud collection is deferred. I still need the measured growth and retention-deletion checks before considering a longer retention window. Those are the next pieces of evidence to collect.

Related posts: One Caddy VM and one wildcard certificate, The warning alerts that vanished after I added phone push, Monitoring the DNS stack.