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:
| Property | Type | Purpose |
|---|---|---|
Name | Title | One-line summary |
Severity | Select: Healthy, Info, Warning, Critical | Set by L1 |
Server | Text | Which host |
Category | Select: SSH, HTTP, Prometheus, Kubernetes, Linux | What kind of check raised it |
Description | Text | Plain-language summary |
Details | Text | The raw health-check output (JSON) |
All Issues | Text | List of check failures |
Recommended Action | Text | What L1 suggests |
Status | Status: Not started, In progress, Done | Lifecycle |
L1 Analysis / L2 Analysis | Text | The two reasoning steps |
Approved By | Text | Who approved |
Approval Time | Date | When |
Executed Command / Command Output | Text | The audit trail |
Timestamp | Date (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:
- Schedule Trigger. Daily, at the time you want tickets waiting for you.
- SSH (one run per server, via a list of hosts and a loop). Execute the script; keep stdout.
- AI / LLM node: L1 analysis. Give it the snapshot and ask for strict JSON:
severity,description,all_issues,recommended_action, and the best-fitcategory. Constrain severity to the four select values. - IF. Stop on
Healthy; continue otherwise. (Every ticket in my data is a warning, so Healthy checks aren’t being ticketed.) - Notion: create database page. Map the fields above, put the raw snapshot in
Details, and leaveStatusas Not started. - 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 happened | Evidence | What 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 tickets | No 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 time | The 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 6 | You 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 Kubernetes | The 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
- 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. - Don’t equate “ran” with “fixed”. Capture the exit code. If the command failed, set a
Failedstatus and keep the ticket open. Better still, run the original check again and only close the ticket when the check passes. - Decide the privilege model on purpose. Either give the executor a narrowly scoped
sudoersrule for exact commands, or have L2 propose only commands that work unprivileged. Letting it propose privileged commands that always fail just burns approvals. - 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. - Record the approver. Fill
Approved Byfrom the approval action itself, not by hand. - 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.
Leave a Comment / Ask a Question
Have thoughts or questions on this article? Drop a message below and Faiez will receive an instant notification.