Server monitoring using only Bash and systemd
If you’re curious about how the small “server information” widget on the Hardware page is put together, it is driven entirely by a Bash script that interrogates the operating system directly. Rather than depending on third-party APIs, background daemons, or monitoring frameworks, the script reads from the interfaces the Linux kernel already provides—primarily files exposed through /proc and /sys, along with a small set of standard userland utilities.
Metrics such as CPU model and frequency, memory availability, system uptime, and load are derived by parsing kernel-provided data structures and command output that reflect the system’s current state at the time of execution. The script is intentionally simple and synchronous: it runs, collects its values, formats them, and exits. Nothing is cached, and nothing persists beyond the moment the information is requested.
The implementation is tuned specifically for my NanoPi NEO server, which runs Ubuntu 22.04 LTS on ARM-based hardware. Because of this, the script assumes a particular kernel version, filesystem layout, and set of available tools. Output formats, device naming conventions, and even the presence of certain files are taken for granted, and these assumptions may not hold on other distributions, architectures, or older systems.
Start with the bash script that generates the JSON output.
#!/usr/bin/env bash
set -e
# ---------- Configuration ----------
DIR_PATH="/var/www/html"
CPU_STAT_FILE="/var/tmp/cpu_stat.prev"
# ---------- Server Time ----------
server_time=$(date -Is)
# ---------- Temperature ----------
temp_file="/sys/class/thermal/thermal_zone0/temp"
if [[ -f "$temp_file" ]]; then
temperature_c=$(awk '{printf "%.1f", $1/1000}' "$temp_file")
else
temperature_c=null
fi
# ---------- Uptime ----------
uptime_seconds=$(awk '{print int($1)}' /proc/uptime)
# ---------- Load ----------
read load1 load5 load15 _ < /proc/loadavg
# ---------- CPU Usage ----------
read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat
idle_time=$((idle + iowait))
total_time=$((user + nice + system + idle + iowait + irq + softirq + steal))
cpu_usage_percent=null
if [[ -f "$CPU_STAT_FILE" ]]; then
read prev_idle prev_total < "$CPU_STAT_FILE"
diff_idle=$((idle_time - prev_idle))
diff_total=$((total_time - prev_total))
if [[ "$diff_total" -gt 0 ]]; then
cpu_usage_percent=$(awk "BEGIN {printf \"%.1f\", (1 - $diff_idle/$diff_total) * 100}")
fi
fi
echo "$idle_time $total_time" > "$CPU_STAT_FILE"
cpu_cores=$(nproc)
# ---------- Memory ----------
mem_total_kb=$(awk '/MemTotal:/ {print $2}' /proc/meminfo)
mem_available_kb=$(awk '/MemAvailable:/ {print $2}' /proc/meminfo)
mem_used_kb=$((mem_total_kb - mem_available_kb))
mem_total_mb=$((mem_total_kb / 1024))
mem_used_mb=$((mem_used_kb / 1024))
mem_used_percent=$(awk "BEGIN {printf \"%.1f\", ($mem_used_kb/$mem_total_kb)*100}")
# ---------- Swap ----------
swap_total_kb=$(awk '/SwapTotal:/ {print $2}' /proc/meminfo)
swap_free_kb=$(awk '/SwapFree:/ {print $2}' /proc/meminfo)
if [[ "$swap_total_kb" -gt 0 ]]; then
swap_used_kb=$((swap_total_kb - swap_free_kb))
swap_total_mb=$((swap_total_kb / 1024))
swap_used_mb=$((swap_used_kb / 1024))
swap_used_percent=$(awk "BEGIN {printf \"%.1f\", ($swap_used_kb/$swap_total_kb)*100}")
else
swap_total_mb=0
swap_used_mb=0
swap_used_percent=null
fi
# ---------- Disk ----------
read disk_total_kb disk_used_kb _ < <(df --output=size,used -k / | tail -1)
disk_total_mb=$((disk_total_kb / 1024))
disk_used_mb=$((disk_used_kb / 1024))
disk_used_percent=$(awk "BEGIN {printf \"%.1f\", ($disk_used_kb/$disk_total_kb)*100}")
# ---------- Directory Usage ----------
if [[ -d "$DIR_PATH" ]]; then
dir_size_kb=$(du -sk "$DIR_PATH" | awk '{print $1}')
dir_size_mb=$((dir_size_kb / 1024))
else
dir_size_mb=null
fi
# ---------- Network ----------
network_json=""
while read -r line; do
iface=$(echo "$line" | awk -F: '{print $1}' | xargs)
rx_bytes=$(echo "$line" | awk '{print $2}')
tx_bytes=$(echo "$line" | awk '{print $10}')
rx_mb=$(awk "BEGIN {printf \"%.1f\", $rx_bytes/1024/1024}")
tx_mb=$(awk "BEGIN {printf \"%.1f\", $tx_bytes/1024/1024}")
network_json+=$(cat <<EOF
"$iface": {
"rx_bytes": $rx_bytes,
"tx_bytes": $tx_bytes,
"rx_mb": $rx_mb,
"tx_mb": $tx_mb
},
EOF
)
done < <(tail -n +3 /proc/net/dev)
network_json=${network_json%,}
# ---------- JSON Output ----------
cat <<EOF
{
"server_time": "$server_time",
"temperature_c": $temperature_c,
"uptime": {
"seconds": $uptime_seconds
},
"load": {
"1min": "$load1",
"5min": "$load5",
"15min": "$load15"
},
"cpu": {
"usage_percent": $cpu_usage_percent,
"cores": $cpu_cores
},
"memory": {
"total_mb": $mem_total_mb,
"used_mb": $mem_used_mb,
"used_percent": $mem_used_percent
},
"swap": {
"total_mb": $swap_total_mb,
"used_mb": $swap_used_mb,
"used_percent": $swap_used_percent
},
"storage": {
"total_mb": $disk_total_mb,
"used_mb": $disk_used_mb,
"used_percent": $disk_used_percent
},
"directory_usage": {
"path": "$DIR_PATH",
"size_mb": $dir_size_mb
},
"network": {
$network_json
}
}
EOF
Write the contents to a file named server-stats.sh, place it in /usr/local/bin/, and update its permissions to make it executable.
chmod +x /usr/local/bin/server-stats.sh
Create a systemd service for your bash script:
sudo nano /etc/systemd/system/server-stats.service
And add the code below into your service file:
[Unit]
Description=Generate server statistics
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/server-stats.sh
StandardOutput=file:/var/www/html/server-stats.json
StandardError=journal
Create a systemd timer:
sudo nano /etc/systemd/system/server-stats.timer
And add the code below into the timer file:
[Unit]
Description=Run server-stats.service every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target
Now reload systemd and enable the timer:
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl enable --now server-stats.timer
To test the output, run the service once and check the output manually:
sudo systemctl start server-stats.service
cat /var/www/html/server-stats.json
{
...
In order to consume and display the output generated by the Bash script, a page on your website needs to include the necessary HTML structure and JavaScript logic to request, parse, and present the data.
<style>
:root {
--good: #2ecc71;
--warn: #f39c12;
--bad: #e74c3c;
}
</style>
<div id="updated" class="small">Loading…</div>
<div class="row"><strong>CPU Temperature:</strong> <span id="temp"></span></div>
<div class="row"><strong>Uptime:</strong> <span id="uptime"></span></div>
<div class="row"><strong>Load (1m / 5m / 15m):</strong> <span id="load"></span></div>
<div class="row">
<div class="label">
<span>CPU Usage:</span>
<span id="cpuText"></span>
</div>
<div class="progress"><div id="cpuBar" class="bar"></div></div>
</div>
<div class="row">
<div class="label">
<span>Memory:</span>
<span id="memText"></span>
</div>
<div class="progress"><div id="memBar" class="bar"></div></div>
</div>
<div class="row">
<div class="label">
<span>Swap:</span>
<span id="swapText"></span>
</div>
<div class="progress"><div id="swapBar" class="bar"></div></div>
</div>
<div class="row">
<div class="label">
<span>Storage:</span>
<span id="diskText"></span>
</div>
<div class="progress"><div id="diskBar" class="bar"></div></div>
</div>
<div class="row">
<div class="label">
<span>Website storage:</span>
<span id="dirText"></span>
</div>
<div class="progress"><div id="dirBar" class="bar"></div></div>
</div>
<div class="row">
<strong>Network traffic:</strong>
<table id="netTable">
<thead>
<tr>
<th>Interface</th>
<th>RX (MB)</th>
<th>TX (MB)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
And the JavaScript code which consumes the above bash script is this:
const REFRESH_MS = 5 * 60 * 1000;
const DATA_URL = 'server-stats.json';
function fetchStats() {
fetch(DATA_URL, { cache: 'no-store' })
.then(r => r.json())
.then(updateUI)
.catch(() => {
document.getElementById('updated').textContent =
'Failed to load server-stats.json';
});
}
function updateUI(d) {
document.getElementById('updated').textContent =
'Updated: ' + new Date(d.server_time).toLocaleString();
document.getElementById('temp').textContent =
d.temperature_c !== null ? `${d.temperature_c} °C` : 'N/A';
document.getElementById('uptime').textContent =
formatUptime(d.uptime.seconds);
document.getElementById('load').textContent =
`${d.load["1min"]} / ${d.load["5min"]} / ${d.load["15min"]}`;
if (d.cpu.usage_percent !== null) {
setBar('cpu', d.cpu.usage_percent,
`${d.cpu.usage_percent}% (${d.cpu.cores} cores)`);
} else {
document.getElementById('cpuText').textContent = 'N/A';
}
setBar('mem', d.memory.used_percent,
`${d.memory.used_mb} / ${d.memory.total_mb} MB`);
if (d.swap.total_mb > 0) {
setBar('swap', d.swap.used_percent,
`${d.swap.used_mb} / ${d.swap.total_mb} MB`);
} else {
document.getElementById('swapText').textContent = 'N/A';
document.getElementById('swapBar').style.width = '0%';
}
setBar('disk', d.storage.used_percent,
`${d.storage.used_mb} / ${d.storage.total_mb} MB`);
const dirPercent = d.storage.total_mb
? (d.directory_usage.size_mb / d.storage.total_mb) * 100
: 0;
setBar('dir', dirPercent,
`${d.directory_usage.size_mb} MB`);
updateNetwork(d.network);
}
function updateNetwork(network) {
const tbody = document.querySelector('#netTable tbody');
tbody.innerHTML = '';
Object.entries(network).forEach(([iface, data]) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${iface}</td>
<td>${data.rx_mb}</td>
<td>${data.tx_mb}</td>
`;
tbody.appendChild(row);
});
}
function setBar(prefix, percent, text) {
const bar = document.getElementById(prefix + 'Bar');
const label = document.getElementById(prefix + 'Text');
bar.style.width = Math.min(percent, 100) + '%';
bar.className = 'bar ' + (
percent < 70 ? 'good' :
percent < 85 ? 'warn' : 'bad'
);
label.textContent = `${text} (${percent.toFixed(1)}%)`;
}
function formatUptime(sec) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return `${d}d ${h}h ${m}m`;
}
fetchStats();
setInterval(fetchStats, REFRESH_MS);
It may look elaborate and difficult to grasp, but in practice it is far simpler than its appearance suggests.