Aller au contenu
Hermès Skills
← Retour au catalogue

vps-diagnostic

Systematic protocol for diagnosing an unresponsive remote Linux server — SSH down but network reachable. Multi-layer connectivity testing, chronological log reconstruction, differential diagnosis, and corrective action planning.

Quand l'utiliser (Trigger)

Déclenchement standard selon le contexte de l'écosystème Hermès.

Mode d'emploi (Usage)

Mode d'emploi standard via l'agent Hermès.

VPS Diagnostic — Server Unreachable Protocol

Overview

When a remote Linux VPS stops responding to SSH but the network layer is still up (ping works, port is open), this skill provides a structured investigation protocol.

Core principle: The symptom pattern tells you the root cause. “SSH banner exchange timeout” is not an SSH problem — it’s a systemic resource exhaustion signal.

When to Use

Trigger conditions:

  • SSH connection times out during banner exchange
  • ping works but SSH hangs or fails
  • Port is open (nmap/nc confirms) but SSH never completes handshake
  • Services return HTTP 503 or are extremely slow
  • You need to determine whether it’s a crash, resource saturation, or network issue

Phase 1 — Multi-Layer Connectivity Testing

Never stop at “SSH doesn’t work.” Test every layer systematically:

Layer 1: ICMP (ping)

ping -c 3 -W 3 <IP>
ResultMeaning
✅ 0% loss, ~100-200msNetwork path OK. Problem is at the host OS level.
✅ 0% loss, >300msHigh latency suggests network congestion or overloaded host.
❌ 100% lossNetwork issue, firewall drop, or host completely powered off.
⚠️ Partial lossNetwork degradation, possible congestion/routing issue.

Layer 2: TCP Port Scan

nc -zv -w5 <IP> <PORT>
nmap -sT -p <PORT> <IP>
ResultMeaning
Port open (nmap confirms)Process is listening but not responding → saturation/block
Port closed (RST)Service is not running
Port filtered (no response)Firewall is blocking

Layer 3: SSH Handshake Debug

ssh -vvv -o LogLevel=DEBUG -o ConnectTimeout=10 user@host -p <port>

Key debug output stages to watch:

Debug messageWhat it means
Connection established.TCP 3-way handshake complete
Local version string SSH-2.0-...Client sent banner, waiting for server
Connection timed out during banner exchangeServer never sent its banner → server too overloaded to fork sshd
Connection closed by <IP>Server actively rejected → MaxStartups exceeded or firewall
Permission deniedAuth problem (different class)

Layer 4: Alternative SSH Options

Try with relaxed key exchange / cipher requirements:

ssh -o KexAlgorithms=+diffie-hellman-group-exchange-sha256 \
    -o Ciphers=+aes256-cbc \
    -o MACs=+hmac-sha1 \
    user@host -p <port>

Failure here too confirms the issue is at process-fork level, not crypto negotiation.

Layer 5: Alternative Port

nc -zv -w5 <IP> 22

If the standard SSH port (22) is also unresponsive but port 2222 is open → confirm the host is reachable at TCP level.

Phase 2 — Log Reconstruction

Gather every source of historical data to build a timeline:

Sources to collect:

# 1. Hermes cron job history
hermes cron list

# 2. Security scanner historical output (look for trends)
cat ~/.hermes/**/cron-vps-*.sh 2>/dev/null
cat ~/shared_log.md

# 3. Service monitor logs
cat ~/vps-mirror/logs/service-monitor*.log
cat ~vps-mirror/hermes-workspace/scripts/integrations/logs/service-monitor.log

# 4. Last known VPS state from local mirrors
cat ~/vps-mirror/logs/master-integration-*.log
ls -la ~/vps-mirror/logs/

# 5. Git baseline (if Git-first was used)
cd <project-dir> && git log --oneline -5

Build a timeline like:

[D/JJ hh:mm]  Dernière connexion SSH réussie
[D/JJ hh:mm]  Dernier scan de sécurité (score X/100)
              → Load N.m (CRITICAL), RAM en WARN
[D/JJ hh:mm]  Services web encore OK mais lents
              (BookStack Nms, n8n Nms)
[D/JJ hh:mm]  Tous les wrappers signalent "VPS inaccessible"
[D/JJ hh:mm]  Dernier cron timeout après 120s

Key metrics to extract from historical data:

MetricWARN thresholdCRITICAL threshold
Load average (5min)> CPU cores × 2> CPU cores × 5
RAM usage> 80%> 90%
Swap usage> 0% (any swap I/O)> 50% used
HTTP response time> 2s> 5s
Paquets en attente> 50 security> 100 security

Output Format Preference

When delivering a diagnostic report to the user, use this concise structure:

  1. État actuel — Emoji-tiered status per subsystem (🟢🟡🔴)
  2. Cause racine — Single paragraph, confirmed by logs, with [FAIT VÉRIFIÉ] or [HYPOTHÈSE UTILE] labels
  3. Vulnérabilités structurelles — Numbered list with priority icons (🔴 critique / 🟠 à planifier / 🟡 surveillé)
  4. Recommandations — Tiered by urgency (Priorité 1/2/3), each with exact commands and rationale
  5. Checklist de validation — Table with emoji status + metric per criterion

Goal: courte, concise, concrète, précise. Every section serves one purpose. No filler paragraphs. Emoji tiers and checklist table do the visual work.

Phase 3 — Differential Diagnosis

Rank hypotheses from most to least probable given the symptom pattern:

Symptom Pattern#1 Hypothesis#2 Hypothesis#3 Hypothesis
ping OK, port open, banner timeout, high historical loadMemory thrashing — RAM saturated → swap I/O 100% → CPU wait queue → fork impossibleDocker/container memory leakOOM killer cycling
ping OK, port CLOSED, previous high loadKernel panic or complete OS crashPower loss (host level)OOM killer killed sshd itself
ping OK, port open, banner timeout, load WAS normalSSH daemon hang/deadlockMaxStartups saturation (brute-force attack)Network namespace issue (Docker)
ping LOSS > 0%, port filteredNetwork/firewall issueDDoS on host/networkRack-level switch problem
100% ping lossHost powered offProvider network outageBilling suspension
OOM cascade confirmed (3+ process kills in logs before crash)Unbounded containers — processes without Docker memory limits consumed all RAM → systemd OOM killed them one by one → global OOMMemory leak in Node.js/PHP process (openclaw, n8n, coolify horizon)Swap exhaustion due to overcommit

OOM Cascade Pattern (3-Wave)

When the OOM killer activates repeatedly before the final crash, the typical progression is:

Wave 1: Largest user process gets killed (openclaw-gateway / n8n / node)
Wave 2: Docker container processes get killed (php / horizon / app workers)
Wave 3: System services get killed (hermes-dashboard, systemd units)
Final:  Global OOM — systemd init.scope killed → SSH fork impossible

Each wave frees some RAM but the remaining processes immediately consume it. By Wave 3 the system is in terminal thrash. The final symptom (banner exchange timeout) is the OOM killer having already executed on everything forkable.

Diagnostic command (after reboot, to see what happened before the crash):

sudo journalctl -b -1 --no-pager 2>/dev/null | grep -i 'oom-kill\|killed by.*OOM\|Failed with result.*oom' | tail -30

The number of oom-kill lines tells you the severity. 1-2 kills → process-specific. 5+ kills → cascade.

Service Restart Counter as Diagnostic Signal

Check cumulative restart counts on systemd services. A service that has restarted tens of thousands of times (e.g., xvfb.service with counter > 50,000) is a chronic CPU/memory drain that worsens resource pressure.

systemctl show xvfb.service -p NRestarts 2>/dev/null
systemctl list-units --state=running --no-pager | grep -v docker

A restart counter in the 10,000+ range means the service has been crash-looping for weeks. Disable it during post-reboot cleanup.

For Contabo VPS (low-cost provider):

Contabo overcommits resources significantly. Host overcommit (noisy neighbour on the hypervisor) is a real possibility when load is high despite normal container/process counts. Include this as a hypothesis when the first scan after reboot shows normal metrics.

Phase 4 — Solution Planning

Step 1: Forced Reboot (if SSH inaccessible)

For Contabo VPS specifically:

  • Panel: https://my.contabo.com
  • Find VPS → Actions → Reset → “Hard Reset” or “Power Cycle”
  • ⚠️ Loss of un-flushed data, possible DB corruption. But necessary.

For other providers, find their equivalent panel:

Step 1A: SSH Protocol Selection — Simple Commands First

When a VPS is borderline (load > 5, memory > 80%), complex SSH commands fail reliably while simple ones work. Specifically:

Command ShapeBehavior on borderline VPS
ssh user@host "simple"✅ Works
ssh user@host "cmd1; cmd2 | grep X | head"⚠️ May timeout (exit 255)
ssh user@host "sudo ..."❌ Often hangs (sudo waits for TTY)
ssh user@host "sudo -n ..."✅ Works
ssh user@host <<'HEREDOC'❌ Almost always timeout
ssh user@host "python3 -c 'code'"❌ Frequent timeout

Rule: On a VPS with load > 5, run individual commands, not chains. Use sudo -n not bare sudo. Prefer multiple SSH connections (each forks a tiny sshd process) over one connection with a long script.

# BAD — will timeout on a loaded VPS (exit 255):
ssh -p 2222 user@host "uptime; free -h; df -h; ps aux --sort=-%mem | head -5"

# GOOD — individual connections, each lightweight:
ssh -p 2222 user@host "uptime"
ssh -p 2222 user@host "free -h"
ssh -p 2222 user@host "df -h"
ssh -p 2222 user@host "ps aux --sort=-%mem | head -5"

Individual connections avoid pipe-blocking, reduce the risk of a single syntax error killing the whole batch, and let you see partial results even if one command fails.

Step 1B: Post-reboot Systemd Service Audit

After a hard reboot, check whether multiple service files try to start the same binary. This accumulates after Hermes re-installs, migrations, or user-service auto-restart loops.

# List all Hermes-related user services
systemctl --user list-units --all 'hermes-*' 2>/dev/null

# Identify which instance a service belongs to:
cat ~/.config/systemd/user/hermes-dashboard.service 2>/dev/null | grep ExecStart

# Kill + disable the orphan:
systemctl --user disable --now hermes-old-instance.service

# After cleanup, verify only the expected services remain
systemctl --user list-units 'hermes-*' --state=running

Common duplicates found in practice:

  • hermes-dashboard.service (legitimate) + hermes-native-dashboard.service (orphan)
  • hermes-webui.service (from old install)
  • hermes-agent.service (system service from package install, stuck in auto-restart)

Also check for services with abnormally high restart counters:

for svc in $(systemctl list-units --state=running --no-pager -o json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); [print(s['unit']) for s in d]" 2>/dev/null); do
  count=$(systemctl show "$svc" -p NRestarts --value 2>/dev/null)
  [ "${count:-0}" -gt 1000 ] && echo "⚠️ $svc — NRestarts=$count"
done

Disable any runaway service (e.g., xvfb.service with restarts > 10,000).

Step 1C: Container Workflow Inspection (n8n pattern)

If n8n runs in Docker and external monitoring is failing, check workflow state from inside the container without interactive sessions:

# Container status and recent errors
docker ps --filter name=n8n --format '{{.Names}} {{.Status}}'
docker logs --tail 50 n8n 2>&1 | grep -i 'error\|fail\|creden'

# Check for failing workflows (bad credentials, dead APIs)
docker exec n8n sqlite3 /home/node/.n8n/database.sqlite \
  "SELECT id, name, active FROM workflow WHERE active=1" 2>/dev/null

# Count recent failed executions
docker exec n8n sqlite3 /home/node/.n8n/database.sqlite \
  "SELECT workflowId, COUNT(*) as fails FROM execution WHERE
   status='error' AND startedAt > datetime('now','-7 days')
   GROUP BY workflowId ORDER BY fails DESC LIMIT 5" 2>/dev/null

A workflow with bad credentials generates error logs but negligible CPU. Diagnose it for the report but don’t prioritize fixing over genuine resource issues.

Step 1E: Post-reboot Script Recovery

After a hard reboot, operational scripts (logging, dashboard, notification) that lived only in ~/ on the VPS filesystem may be missing or incomplete (unsynced writes lost on hard reset, filesystem in inconsistent state).

Check which critical scripts are missing:

for f in ~/.hermes/scripts/hermes-logger.py ~/.hermes/scripts/hermes-dashboard.py ~/.hermes/scripts/notify.py; do
  [ -f "$f" ] && echo "✅ $f" || echo "🔴 MISSING: $f"
done

Restore from local mirror (mx → VPS):

If the scripts exist on mx (local machine) but not on the VPS, sync them back:

# From mx, push each missing script to the VPS
SCRIPT_DIR=~/.hermes/scripts
scp -P 2222 -i ~/.ssh/id_vps \
  "$SCRIPT_DIR/hermes-logger.py" \
  "$SCRIPT_DIR/hermes-dashboard.py" \
  "$SCRIPT_DIR/notify.py" \
  bf@173.212.194.102:~/.hermes/scripts/

# Make them executable on the VPS
ssh -p 2222 -i ~/.ssh/id_vps bf@173.212.194.102 \
  "chmod +x ~/.hermes/scripts/hermes-*.py ~/.hermes/scripts/notify.py"

Verify Google Sheets credentials:

The OAuth token (~/.config/gspread/token.json) may have expired during the downtime. Test with a dry-run log entry:

ssh -p 2222 -i ~/.ssh/id_vps bf@173.212.194.102 \
  "cd ~/.hermes/scripts && python3 hermes-logger.py \
  'Post-crash recovery - Scripts restored' \
  'VPS hard reboot — logging scripts re-synced from mx' \
  'OK' 'recovery' --notify --level info"

If the token expired:

  1. Copy fresh tokens from mx: scp -P 2222 -i ~/.ssh/id_vps ~/.config/gspread/token.json bf@173.212.194.102:~/.config/gspread/
  2. Or re-authenticate on the VPS: gspread will prompt for OAuth
  3. Or update the refresh token directly via the Google OAuth API

Log the recovery to shared_log.md:

After restoring, record the action:

[2026-06-06 HH:MM] | mx | hermes | post-crash-script-recovery | ✅ | hash_placeholder

Pitfalls:

PitfallSolution
~/.hermes/scripts/ doesn’t exist on VPS after resetssh ... "mkdir -p ~/.hermes/scripts" before scp
gspread Python package missing on VPSInstall: python3 -m pip install gspread google-auth
Token expired AND no refresh tokenRun OAuth device flow manually: python3 -m gspread.auth on the VPS
Scripts use hardcoded absolute paths that differ between machinesAll Hermes scripts under ~/.hermes/scripts/ use os.path.expanduser("~") — same user bf on both machines, paths resolve identically

Step 1D: Instance Comparison (Hermes)

When two Hermes instances exist on the same machine (~/.hermes/ vs ~/.hermes-native/), compare their skills to decide which to keep:

# Compare skill sets
diff <(ls ~/.hermes/skills/) <(ls ~/.hermes-native/skills/) | head -40

# Unique skills per instance
comm -23 <(ls ~/.hermes/skills/ | sort) <(ls ~/.hermes-native/skills/ | sort)
# → only in old instance, candidates for copying

# Which instance is active?
readlink -f ~/.local/bin/hermes

Policy: Keep the instance the running gateway/dashboard uses. Copy valuable unique skills from the retiring instance before removal.

Step 2: Post-Reboot Triage (first 5 minutes)

# A. Vital signs
uptime
free -h
df -h
cat /proc/meminfo | grep -E 'MemTotal|MemFree|MemAvailable|Swap'

# B. OOM killer history (@systemd-oomd is rarely installed — grep journal directly)
sudo journalctl -b -1 --no-pager 2>/dev/null | grep -i 'oom-kill\|killed by.*OOM\|Failed with result.*oom' | tail -30

# C. Previous boot list (find when previous boot started/ended)
journalctl --list-boots 2>/dev/null

# D. Crash file inventory (/var/crash/ accumulates apport dumps)
ls -la /var/crash/ 2>/dev/null | tail -20

# E. Process ranking by RAM
ps aux --sort=-%mem | head -20

# F. Docker container stats and memory limits check
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'
# Verify which containers lack memory limits (HostConfig.Memory=0 means unlimited):
for c in $(docker ps -q); do
  name=$(docker inspect --format '{{.Name}}' "$c" | sed 's|^/||')
  mem=$(docker inspect --format '{{.HostConfig.Memory}}' "$c")
  [ "$mem" = "0" ] && echo "🔴 $name — NO MEMORY LIMIT (unlimited)"
done

# G. Docker container logs (tail errors per container)
for c in $(docker ps -a -q); do
  echo "=== $(docker inspect --format '{{.Name}}' $c | sed 's|^/||') ==="
  docker logs --tail 30 $c 2>&1 | grep -i 'error\|fatal\|panic\|OOM\|killed\|crash\|Exception' | tail -5
done

# H. System logs before crash (boot -1)
sudo journalctl -b -1 --no-pager 2>/dev/null | grep -i 'panic\|crash\|hung_task\|soft lockup\|rcu stall' | tail -10
# User-systemd units OOM data
journalctl --user -b -1 --no-pager 2>/dev/null | grep -i 'oom-kill\|killed' | tail -20
# Hermes-specific services
for svc in hermes-gateway hermes-dashboard openclaw-gateway; do
  journalctl --user -b -1 -u $svc --no-pager 2>/dev/null | grep -i 'oom\|killed\|Failed' | tail -5
done

# I. SSH daemon status and brute-force pressure
sudo systemctl status sshd --no-pager -l
sudo journalctl -b -1 -u sshd --no-pager -n 20 2>/dev/null
sudo fail2ban-client status sshd 2>/dev/null  # check banned IP count for attack pressure

# J. Swap and I/O diagnosis
sudo iostat -x 1 3 2>/dev/null
sudo swapon --show

# K. Service restart counters (find runaway crash-looping services)
for svc in $(systemctl list-units --state=running --no-pager --no-legend 2>/dev/null | awk '{print $1}' | head -50); do
  restarts=$(systemctl show "$svc" -p NRestarts --value 2>/dev/null)
  [ -n "$restarts" ] && [ "$restarts" -gt 1000 ] 2>/dev/null && echo "⚠️ $svc — NRestarts=$restarts"
done

Step 3: Apply Permanent Fixes

Fix 1 — Docker resource limits (prevents single container from starving the system):

docker update --memory=2g --memory-swap=3g <container>
docker update --memory=1g --memory-swap=2g <container>

Fix 2 — OOM pressure watchdog (alert-only, no auto-kill) (cron, every 5 min):

*/5 * * * * /usr/bin/free | awk '/Mem:/ {if ($3/$2 > 0.85) print "⚠️ RAM à "$3/$2*100"% sur $(hostname)"}' | xargs -r ~/.hermes/scripts/notify.py

Never include auto-kill/restart logic in a watchdog without explicit user consent. The user may prefer to investigate why pressure is rising rather than restarting the process that holds the evidence.

Fix 3 — vm.overcommit_memory=1 (prevents Redis/Postgres from failing under pressure):

sudo sysctl vm.overcommit_memory=1
echo 'vm.overcommit_memory=1' | sudo tee -a /etc/sysctl.conf

Fix 4 — Journald log size limit:

sudo journalctl --vacuum-size=200M
# Make permanent:
sudo sed -i 's/#SystemMaxUse=/SystemMaxUse=200M/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald

Fix 5 — Swap tuning (reduce swappiness on a memory-constrained VPS):

sudo sysctl vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-swap.conf

Fix 6 — Fail2ban MaxStartups tuning (prevent SSH connection table saturation):

# In /etc/ssh/sshd_config:
# MaxStartups 10:30:100
# → increase to: MaxStartups 30:30:200
sudo systemctl restart sshd

Fix 7 — Systemd user-unit OOM policies (prevents death-spiral restart on OOM):

# Create drop-in for user services:
mkdir -p ~/.config/systemd/user/<service>.service.d/
cat > ~/.config/systemd/user/<service>.service.d/oom.conf << 'EOF'
[Service]
OOMPolicy=stop
MemoryMax=512M
MemoryHigh=384M
MemorySwapMax=128M
RestartForceExitStatus=75
EOF
systemctl --user daemon-reload

This stops systemd from restarting an OOM-killed process into the same pressure, and caps the unit’s memory so it’s a target before non-systemd processes. Apply to: hermes-gateway, hermes-dashboard, openclaw-gateway.

Fix 8 — Service crash-loop detection and cleanup: After reboot, check for services with abnormally high restart counters (hundreds or thousands of restarts = chronic drain):

# Find crash-looping services
for svc in $(systemctl list-units --state=running --no-pager --no-legend 2>/dev/null | awk '{print $1}' | head -100); do
  restarts=$(systemctl show "$svc" -p NRestarts --value 2>/dev/null)
  [ -n "$restarts" ] && [ "$restarts" -gt 100 ] 2>/dev/null && echo "$restarts restarts: $svc"
done

# Disable a confirmed drain (e.g., xvfb.service)
sudo systemctl stop xvfb.service
sudo systemctl disable xvfb.service

Fix 9 — Alerting on threshold (via Hermes cron):

{
  "name": "vps-watchdog",
  "schedule": "*/5 * * * *",
  "script": "vps-watchdog.sh",
  "no_agent": true
}

Script checks load/memory/swap and sends Telegram alert if thresholds exceeded.

Memory Budgeting Checklist (11GB VPS)

After identifying all memory consumers post-reboot, build a budget to prevent future OOM:

Total Host RAM:     ~11 GB
Reserve for OS:     ~2 GB   (kernel, daemons, page cache)
Available for apps: ~9 GB

Container limits (example):
  n8n                    = 1 GB
  coolify (PHP+Horizon)  = 1 GB
  coolify-db (Postgres)  = 1 GB
  coolify-realtime       = 512 MB
  bookstack (Apache+PHP) = 256 MB
  mariadb                = 256 MB
  viikunja               = 256 MB
  postgresql (Vikunja)   = 256 MB
  silverbullet           = 128 MB
  quartz                 = 128 MB
  dashboard              = 512 MB
  coolify-proxy (Traefik)= 256 MB
  Postgres (other)       = 256 MB
  Various redis/redis    = 64-128 MB each
  ---
  Docker total caps     ~5.5 GB

Non-Docker processes:
  n8n workers (bare)     = 300 MB
  Hermes gateways (×2)   = 512 MB each
  getscreen.me            = 200 MB
  rustdesk                = 200 MB
  OpenClaw gateway        = 512 MB
  Traefik (bare)          = 100 MB
  ---
  Non-Docker total       ~2.4 GB

Total budget            ~7.9 GB → headroom ~3.1 GB for bursts

If total caps exceed available RAM (9 GB), reduce unessential services or upgrade VPS. The goal: even with all services at their limit, the system stays in RAM and avoids swap entirely.

Phase 5 — Failure-proofing

Backup SSH access

  • Tailscale or WireGuard as a secondary SSH path (bypasses public port)
  • Add to cron: tailscale up --accept-routes on both sides

External healthcheck

  • Hermes cron job from local machine that tests SSH connectivity
  • If SSH fails → Telegram alert within 1 minute
  • Use vps-connect.sh pattern:
    VPS_SSH="ssh -p 2222 -i ~/.ssh/id_vps -o ConnectTimeout=5 -o BatchMode=yes user@host"
    if ! $VPS_SSH "exit" 2>/dev/null; then
      ~/.hermes/scripts/notify.py "🚨 VPS SSH DOWN"
    fi

Document the provider panel procedure

Save in the project context file or a reference doc:

  • Provider name + panel URL
  • Login method (credentials / SSH key / SSO)
  • Reset procedure steps
  • Support contact

Pitfalls

  • Don’t diagnose “SSH problem” when it’s a system saturation problem. The banner exchange timeout is the hallmark of kernel-level thrashing. Fix the resources, not the daemon.
  • Don’t skip log reconstruction. The most recent scanner output often contains the exact metrics that explain the crash (load, memory, swap).
  • Don’t hard-reboot without checking if it’s the only option. Try alternate SSH ports (Tailscale IP, port 22) first.
  • Don’t restart Docker containers without saving logs first. Container logs are lost on restart. Save them for post-mortem.
  • Don’t ignore the OOM killer. If the kernel killed processes, the /var/log/kern.log tells you exactly what, when, and why.
  • Don’t miss the OOM cascade pattern. When you see 3+ separate oom-kill entries in journalctl spanning different processes across several minutes, it’s an OOM cascade (process-by-process killing), not a single process crash. The correct response is Docker memory limits on ALL containers, not just isolating one leaky process.
  • Don’t forget to vacuum journald. Non-rotated systemd journals can grow to >1GB on a busy VPS, contributing to disk/swap pressure. Make the size limit permanent: sudo sed -i 's/#SystemMaxUse=/SystemMaxUse=200M/' /etc/systemd/journald.conf && sudo systemctl restart systemd-journald.
  • Don’t include auto-kill/restart logic in memory watchdogs without explicit user consent. Users may prefer to investigate what caused the pressure rather than restarting the process that holds the evidence. Always make watchdogs alert-only by default.
  • Don’t ignore /var/crash/ accumulation. Apport crash dumps (systemd-logind, rustdesk, getscreen.me) can grow to 10-30MB per crash. A set of crashes just before the OOM freeze confirms the cascade. Clean them after investigation: sudo rm -f /var/crash/*
  • Don’t forget to set Docker memory limits on ELIM containers, not just the big ones. A single unconstrained container (e.g., mariadb, bookstack) left at host-max can consume the last 1-2GB of RAM and tip the system into swap.
  • Don’t run complex SSH command chains on a borderline VPS. When load > 5, piped commands (ssh host "cmd1 | cmd2 | head"), heredocs (ssh host <<'EOF'), and nested quoting (ssh host "python3 -c '...") consistently time out with exit 255 while individual simple commands (ssh host "uptime", ssh host "free -h") succeed. Each pipe stage adds a blocking wait that compounds on a loaded system. Run multiple simple connections instead of one chained one.
  • Don’t use bare sudo over SSH without -n. Without -n, sudo waits for a TTY prompt, which hangs over non-interactive SSH. Always sudo -n or use ssh -t for interactive sudo.
  • Don’t forget to check for duplicate systemd services post-reboot. After a crash, multiple unrelated service files may have accumulated (from migrations, re-installs, or orphaned auto-restarts). All may try to start the same binary simultaneously, wasting CPU and memory. Run systemctl --user list-units --all 'hermes-*' 2>/dev/null and compare against expected. Kill services that belong to an old or migrated instance.
  • Don’t rely on docker exec for interactive queries inside containers on poor-connectivity. Use non-interactive single-shot commands: docker exec <container> sqlite3 /path/db "SELECT count(*) FROM workflows" — but these can also fail under extreme load. Prefer container logs (docker logs --tail 30 <container>) for a first-pass read.
  • Don’t ignore fail2ban as a diagnostic signal. A high banned IP count (> 30) combined with ongoing failed attempts during the crash period suggests SSH brute-force contributed to the load. Check sudo fail2ban-client status sshd after reboot.
  • Don’t skip checking service restart counters post-reboot. A service with NRestarts > 1,000 has been crash-looping for days/weeks, consuming CPU and memory that compounds resource pressure. Find and disable it.

Quick Reference Card

Phase                   Tools                          Outcome
─────                   ─────                          ───────
1. Multi-layer test     ping → nc → nmap → ssh -vvv    Symptom pattern
2. Log reconstruction   cron list → logs → scanner     Timeline
3. Differential dx      Pattern × metrics              Root cause hypothesis
4. Solution plan        Panel reboot → post-triage     Recovery + hardening
5. Failure-proof        Tailscale + watchdog            Future resilience

References

See references/post-crash-script-recovery-20260606.md for the specific script recovery steps, token renewal procedure, and retroactive logging commands (scripts lost after VPS hard reboot). See references/crash-diagnostic-session-20260606.md for the pre-crash investigation (SSH banner timeout → log reconstruction → provider reset). See references/post-crash-corrective-session-20260606.md for the post-reboot OOM cascade analysis, memory budgeting, and corrective measures applied. See references/post-crash-cleanup-session-20260606.md for the duplicate process detection, service audit, N8n inspection, and Hermes instance comparison (same incident, second phase). See references/post-crash-oom-cascade-20260606.md for the documented 3-wave OOM cascade from this session, including exact dmesg timestamps, container states, and the mitigation applied.

Absorbed: Site Diagnostics

references/site-diagnostics-workflow.md — Systematic investigation of down/unreachable websites: CNAME/DNS checks, TLS verification, HTTP status, response time, and content validation. Previously a standalone skill (site-diagnostics).

Absorbed: Stack Context Loading

references/stack-context-loading.md — Rapid VPS stack snapshot for loading context BEFORE starting a DevOps task: Docker, Coolify, disk, services, health check. Previously a standalone skill (stack-context). Use this for pre-task context loading rather than full diagnostics.