I wanted a boring, useful piece of operations automation: every day, look at my servers, decide whether anything needs attention, and write it up as a ticket — then, if I approve, let a second step try the fix and record what happened.

This is a pilot built from three pieces: n8n for orchestration, a Notion database as the ticket system and audit log, and an LLM doing the L1 and L2 analysis. It’s the small, practical cousin of the design in An Autonomous IT Team with a Hard Approval Gate.

What this post is. A setup guide, plus an honest review of what 20 real tickets showed. I can see the ticket database and its output, but I’m not publishing the exact n8n workflow export, so the n8n steps below describe how to wire it from the evidence, not a copy of a working file. Anything I reconstructed is marked.


The flow

Schedule (daily)
   │
   ▼
SSH health check on each server          ← read-only snapshot
   │
   ▼
L1 analysis (LLM)                        ← severity, description, recommended action
   │   Healthy? → stop.  Otherwise:
   ▼
Create ticket in Notion  (Status: Not started)
   │
   ▼
Human approval                           ← sets Approval Time
   │
   ▼
L2 analysis (LLM)                        ← reads ticket, proposes ONE command
   │
   ▼
SSH execute that command
   │
   ▼
Write back: Executed Command, Command Output, Status: Done

In my data, most tickets are timestamped within a minute of midnight UTC, which fits a daily schedule; a few carry other times, which fits manual or test runs. The L2 steps happen later, once the ticket has been approved.


Step 1: the Notion database

The database is the heart of it: the ticket, the analysis, the approval and the execution record all live in one row. These are the properties I used:

PropertyTypePurpose
NameTitleOne-line summary
SeveritySelect: Healthy, Info, Warning, CriticalSet by L1
ServerTextWhich host
CategorySelect: SSH, HTTP, Prometheus, Kubernetes, LinuxWhat kind of check raised it
DescriptionTextPlain-language summary
DetailsTextThe raw health-check output (JSON)
All IssuesTextList of check failures
Recommended ActionTextWhat L1 suggests
StatusStatus: Not started, In progress, DoneLifecycle
L1 Analysis / L2 AnalysisTextThe two reasoning steps
Approved ByTextWho approved
Approval TimeDateWhen
Executed Command / Command OutputTextThe audit trail
TimestampDate (with time)When the check ran

To let n8n write to it: create a Notion internal integration, copy its secret into an n8n credential, then share the database with that integration (the step everyone forgets, and the one that produces “object not found” errors).

Step 2: the health-check script

The ticket’s Details field is a single JSON blob with named sections. From that output, the check collects: disk, memory, load, the state of a few monitored services, Docker, Kubernetes nodes and non-running pods, and recent journal errors. Here is a script that produces the same sections. I reconstructed the exact commands from the output format, so adapt the service names and paths:

#!/usr/bin/env bash
# Read-only snapshot. Sections match the ticket "Details" field.
echo "===DISK===";    df -h / | tail -1
echo "===MEMORY===";  free -h | grep Mem
echo "===LOAD===";    uptime
echo "===SERVICES==="
for u in <service-1> <service-2> <service-3>; do systemctl is-active "$u"; done
echo "===DOCKER_PS==="
docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null || echo docker-not-running
echo "===K8S_NODES==="
kubectl get nodes 2>/dev/null || echo kubectl-not-available
echo "===K8S_NON_RUNNING_PODS==="
kubectl get pods -A --field-selector=status.phase!=Running --no-headers 2>/dev/null
echo "===JOURNALCTL==="
journalctl -p err --since "-24h" -n 20 --no-pager 2>&1

Two rules for this step. Keep it strictly read-only, so the check can never be the thing that breaks a server. And run it as an unprivileged user with a dedicated SSH key, ideally restricted to this one command with command="..." in authorized_keys.

Step 3: wire it in n8n

The nodes, in order:

  1. Schedule Trigger. Daily, at the time you want tickets waiting for you.
  2. SSH (one run per server, via a list of hosts and a loop). Execute the script; keep stdout.
  3. AI / LLM node: L1 analysis. Give it the snapshot and ask for strict JSON: severity, description, all_issues, recommended_action, and the best-fit category. Constrain severity to the four select values.
  4. IF. Stop on Healthy; continue otherwise. (Every ticket in my data is a warning, so Healthy checks aren’t being ticketed.)
  5. Notion: create database page. Map the fields above, put the raw snapshot in Details, and leave Status as Not started.
  6. Notification (email or Telegram) with a link to the ticket.

Step 4: the approval step, and L2

As I’d wire it, a second workflow handles approved tickets. It picks up a ticket after approval, runs an L2 analysis that reads the ticket and proposes a single command, executes it over SSH, then writes Executed Command, Command Output and Status back. (The database fields show this sequence happened; the trigger mechanism is the part I’m describing rather than copying.)

n8n has built-in “send and wait for approval” nodes for email and chat that produce Approve/Reject links, which is the simplest way to get a real approval without building a UI. Whatever you use, store who approved and when. (My data shows why; see below.)

The first thing I ran through it was a deliberately harmless test ticket: L2 itself noted it was a test escalation to validate the approval-and-execute path, and the command was a read-only uptime && systemctl is-system-running. Test the pipe with something that can’t hurt.


What 20 real tickets showed

Over about two weeks the database collected 20 rows: 19 warnings (the other row is empty), 18 filed under Kubernetes, 6 Done and 14 Not started. The pattern in them is more useful than the setup.

What happenedEvidenceWhat it means
The same problem keeps getting a new ticket.A failed logrotate.service produced a fresh ticket on at least five different days (three closed as Done, two still open); “Docker not running” and “sustained high load” recur across ticketsNo deduplication. A daily check turns one persistent fault into a daily ticket
A fix failed three times in a row and the ticket was marked Done.The proposed systemctl reset-failed logrotate.service && systemctl start … returned “Interactive authentication required” each timeThe unprivileged SSH user can’t run it, and Status: Done meant “a command ran”, not “the problem is fixed”
A broad command was proposed.One L2 step proposed restarting every failed unit: `systemctl list-units –state=failed …xargs -r systemctl restart`
Approval time is recorded; the approver isn’t.Approval Time is set on all 6 Done tickets, Approved By is empty on all 6You can see when but not who, which is half an audit trail
The category doesn’t always match the evidence.Hosts reporting kubectl-not-available were still filed under KubernetesThe category follows the check list, not the actual fault
One fix appeared to work.systemctl enable --now docker ran on a host where Docker had stopped, and the recorded output shows the enable step executing (it’s truncated, so I can’t see whether Docker then stayed up)Narrow, well-understood commands are what this pattern suits. It also shows why the ticket needs a re-check

None of this is a failure of the idea. It’s what a pilot is for.

What I’d change

  1. Deduplicate. Key each ticket on server + failing check. If one is open, update it (add the new snapshot, bump a counter) instead of creating another.
  2. Don’t equate “ran” with “fixed”. Capture the exit code. If the command failed, set a Failed status and keep the ticket open. Better still, run the original check again and only close the ticket when the check passes.
  3. Decide the privilege model on purpose. Either give the executor a narrowly scoped sudoers rule for exact commands, or have L2 propose only commands that work unprivileged. Letting it propose privileged commands that always fail just burns approvals.
  4. Constrain what L2 can propose. An allow-list of command shapes, one target at a time, no pipes into xargs. Hash the approved command and compare before executing, as in the blueprint.
  5. Record the approver. Fill Approved By from the approval action itself, not by hand.
  6. Add a Failed and a Healthy path. Tickets should be able to end in “tried, didn’t work, needs a human”, and a quiet day should leave a heartbeat so silence means “healthy” rather than “the workflow stopped”.

The point of putting a human between L1 and L2 is that approvals are only as good as what the human can see. A ticket that shows the exact command, the target, the previous failures and the approver gives the gate something real to decide on.