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>
| Result | Meaning |
|---|---|
| ✅ 0% loss, ~100-200ms | Network path OK. Problem is at the host OS level. |
| ✅ 0% loss, >300ms | High latency suggests network congestion or overloaded host. |
| ❌ 100% loss | Network issue, firewall drop, or host completely powered off. |
| ⚠️ Partial loss | Network degradation, possible congestion/routing issue. |
Layer 2: TCP Port Scan
nc -zv -w5 <IP> <PORT>
nmap -sT -p <PORT> <IP>
| Result | Meaning |
|---|---|
| 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 message | What 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 exchange | Server never sent its banner → server too overloaded to fork sshd |
Connection closed by <IP> | Server actively rejected → MaxStartups exceeded or firewall |
Permission denied | Auth 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:
| Metric | WARN threshold | CRITICAL 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:
- État actuel — Emoji-tiered status per subsystem (🟢🟡🔴)
- Cause racine — Single paragraph, confirmed by logs, with [FAIT VÉRIFIÉ] or [HYPOTHÈSE UTILE] labels
- Vulnérabilités structurelles — Numbered list with priority icons (🔴 critique / 🟠 à planifier / 🟡 surveillé)
- Recommandations — Tiered by urgency (Priorité 1/2/3), each with exact commands and rationale
- 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 load | Memory thrashing — RAM saturated → swap I/O 100% → CPU wait queue → fork impossible | Docker/container memory leak | OOM killer cycling |
| ping OK, port CLOSED, previous high load | Kernel panic or complete OS crash | Power loss (host level) | OOM killer killed sshd itself |
| ping OK, port open, banner timeout, load WAS normal | SSH daemon hang/deadlock | MaxStartups saturation (brute-force attack) | Network namespace issue (Docker) |
| ping LOSS > 0%, port filtered | Network/firewall issue | DDoS on host/network | Rack-level switch problem |
| 100% ping loss | Host powered off | Provider network outage | Billing 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 OOM | Memory 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:
- Hetzner: https://console.hetzner.cloud
- DigitalOcean: https://cloud.digitalocean.com
- Linode: https://cloud.linode.com
- Vultr: https://my.vultr.com
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 Shape | Behavior 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:
- Copy fresh tokens from mx:
scp -P 2222 -i ~/.ssh/id_vps ~/.config/gspread/token.json bf@173.212.194.102:~/.config/gspread/ - Or re-authenticate on the VPS:
gspreadwill prompt for OAuth - 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:
| Pitfall | Solution |
|---|---|
~/.hermes/scripts/ doesn’t exist on VPS after reset | ssh ... "mkdir -p ~/.hermes/scripts" before scp |
gspread Python package missing on VPS | Install: python3 -m pip install gspread google-auth |
| Token expired AND no refresh token | Run OAuth device flow manually: python3 -m gspread.auth on the VPS |
| Scripts use hardcoded absolute paths that differ between machines | All 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-routeson 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.shpattern: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.logtells 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
sudoover SSH without-n. Without-n, sudo waits for a TTY prompt, which hangs over non-interactive SSH. Alwayssudo -nor usessh -tfor 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/nulland compare against expected. Kill services that belong to an old or migrated instance. - Don’t rely on
docker execfor 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 sshdafter 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.