I like monitoring systems that make their assumptions obvious. Prometheus is one of them. It scrapes targets on a schedule, stores time series locally, evaluates rules, and fires alerts when conditions match. There is no mystery about where the numbers came from. If a graph looks wrong, I can usually inspect the raw metric in a few seconds and work backward.
That transparency is why I keep coming back to Prometheus for Linux infrastructure. It is not the only monitoring stack I use, and it is not automatically the right one for every environment. But for server metrics, service health, and quick operational visibility, Prometheus plus Node Exporter is still one of the most practical setups you can deploy.
This guide focuses on the part I actually use on Linux hosts: installing Prometheus, installing Node Exporter, writing a sane prometheus.yml, adding alert rules, thinking about retention, and not leaving the whole thing exposed to the internet with no authentication.
If you want a broader certificate-focused monitoring view as well, the SSL certificate alerting guide and the Zabbix certificate monitoring article are natural companions. For automated housekeeping around backups or reports, the systemd timers guide is also relevant because I often pair Prometheus with periodic maintenance jobs.
Understand the architecture before you install anything
Prometheus is usually easy to install. The harder part is understanding how it wants you to think.
Core pieces:
- Prometheus server: scrapes targets, stores metrics, evaluates rules
- exporters: expose metrics over HTTP, such as Node Exporter for Linux hosts
- Alertmanager: receives alerts and handles routing, grouping, silencing, inhibition
- Grafana: optional visualization layer
- Pushgateway: used for short-lived batch jobs that cannot be scraped directly
Pull-based versus push-based monitoring
Prometheus is primarily pull-based. That means Prometheus calls the target.
I prefer this for infrastructure metrics because:
- scrape timing is centralized
- target configuration is easier to audit
- failed scrapes are visible as failures, not silent data gaps
- there is less agent-side state to manage
Push-based workflows still exist, but in Prometheus land they are the exception. Use Pushgateway for short-lived jobs, not as a generic replacement for scrapes.
A common mistake is trying to push everything because another monitoring system worked that way. That usually leads to awkward designs.
Install Prometheus on Linux
I usually install Prometheus from the official binary release when I want predictable upstream behavior. Distro packages can be fine, but they are sometimes older than I want.
Create a service account and directories:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin prometheus
sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo chown prometheus:prometheus /var/lib/prometheus
Download and extract a release. Replace the version with the current one you intend to deploy:
cd /usr/local/src
sudo curl -LO https://github.com/prometheus/prometheus/releases/download/v2.54.1/prometheus-2.54.1.linux-amd64.tar.gz
sudo tar -xzf prometheus-2.54.1.linux-amd64.tar.gz
Install binaries and consoles:
sudo cp /usr/local/src/prometheus-2.54.1.linux-amd64/prometheus /usr/local/bin/
sudo cp /usr/local/src/prometheus-2.54.1.linux-amd64/promtool /usr/local/bin/
sudo cp -r /usr/local/src/prometheus-2.54.1.linux-amd64/consoles /etc/prometheus/
sudo cp -r /usr/local/src/prometheus-2.54.1.linux-amd64/console_libraries /etc/prometheus/
sudo chown -R prometheus:prometheus /etc/prometheus
Create prometheus.yml
A minimal starting config:
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: prometheus
static_configs:
- targets:
- 127.0.0.1:9090
- job_name: node
static_configs:
- targets:
- 127.0.0.1:9100
I start small. Over-engineering discovery on day one is not a virtue.
Create the systemd service
[Unit]
Description=Prometheus
Wants=network-online.target
After=network-online.target
[Service]
User=prometheus
Group=prometheus
Type=simple
ExecStart=/usr/local/bin/prometheus \
--config.file=/etc/prometheus/prometheus.yml \
--storage.tsdb.path=/var/lib/prometheus \
--web.console.templates=/etc/prometheus/consoles \
--web.console.libraries=/etc/prometheus/console_libraries \
--storage.tsdb.retention.time=30d
Restart=on-failure
[Install]
WantedBy=multi-user.target
Save it as /etc/systemd/system/prometheus.service, then validate and start:
sudo promtool check config /etc/prometheus/prometheus.yml
sudo systemctl daemon-reload
sudo systemctl enable --now prometheus
sudo systemctl status prometheus --no-pager
If you want more detail on writing robust unit files, the systemd unit file guide is a good reference.
Install Node Exporter
Node Exporter is the easiest part of the stack. It exposes host-level Linux metrics over HTTP.
Create the service account:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin node_exporter
Download and install it:
cd /usr/local/src
sudo curl -LO https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz
sudo tar -xzf node_exporter-1.8.2.linux-amd64.tar.gz
sudo cp /usr/local/src/node_exporter-1.8.2.linux-amd64/node_exporter /usr/local/bin/
Systemd unit:
[Unit]
Description=Prometheus Node Exporter
Wants=network-online.target
After=network-online.target
[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter
Restart=on-failure
[Install]
WantedBy=multi-user.target
Save it as /etc/systemd/system/node_exporter.service, then start it:
sudo systemctl daemon-reload
sudo systemctl enable --now node_exporter
sudo systemctl status node_exporter --no-pager
Confirm the endpoint responds:
curl -fsS http://127.0.0.1:9100/metrics | head
If that command returns plain-text metrics, the exporter is alive.
What Node Exporter gives you
Node Exporter covers the standard Linux host metrics I care about most:
- CPU usage and load
- memory usage and pressure signals
- disk space and inode availability
- filesystem mount metrics
- network counters and error rates
- system uptime
- entropy, file descriptor, and basic kernel stats
It does not replace application-specific exporters. That distinction matters.
If you want MySQL internals, use a MySQL exporter. If you want Nginx request metrics, use an Nginx exporter or app instrumentation. If you want TLS certificate expiry, Node Exporter alone is not enough.
TLS certificate expiry with Prometheus
This is where people often assume Node Exporter should do more than it does.
For certificate expiry monitoring, I usually use one of these:
- Blackbox Exporter for probing HTTPS endpoints
- x509 exporter for filesystem or endpoint certificate inspection
- a custom script exporter for specific edge cases
A practical alert rule with Blackbox Exporter looks like this:
groups:
- name: tls-alerts
rules:
- alert: TLSCertificateExpiringSoon
expr: (probe_ssl_earliest_cert_expiry - time()) < 30 * 24 * 3600
for: 15m
labels:
severity: warning
annotations:
summary: TLS certificate expires within 30 days
description: "{{ $labels.instance }} has a certificate expiring within 30 days"
I am calling that out explicitly because trying to invent certificate expiry from generic Node Exporter metrics is the wrong approach.
Add the scrape targets properly
Once Node Exporter runs on a monitored host, add it to Prometheus.
Single server example:
scrape_configs:
- job_name: node
static_configs:
- targets:
- 192.0.2.10:9100
- 192.0.2.11:9100
If your Prometheus server cannot reach the exporter port, fix that before chasing config ghosts.
Quick network validation:
curl -fsS http://192.0.2.10:9100/metrics | head
Or test the TCP port:
nc -vz 192.0.2.10 9100
I usually bind exporters on internal addresses only and protect access with firewall rules.
PromQL basics I use constantly
PromQL is one of the reasons Prometheus is powerful. The basics are not hard, but they are easy to misuse.
rate()
Use rate() for steadily increasing counters across a time window.
Example: CPU busy percentage from idle time inversion:
100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])))
I use rate() for most dashboard views because it smooths short spikes better than irate().
irate()
irate() uses the last two points and is more volatile.
irate(node_network_receive_bytes_total[5m])
This is useful when I want to see sharp short-term changes, but it is often too noisy for alerts.
histogram_quantile()
You will not use this much with Node Exporter itself, but it matters for application latency histograms.
Example:
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
That gives a 95th-percentile latency estimate based on histogram buckets. When a service exports histograms correctly, this is far more useful than simple averages.
Useful Linux queries I keep around
Memory available percentage:
100 * node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
Filesystem free percentage ignoring ephemeral filesystems:
100 * node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"} /
node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs"}
Disk read throughput:
rate(node_disk_read_bytes_total[5m])
Network receive throughput:
rate(node_network_receive_bytes_total{device!="lo"}[5m])
Recording rules: do the expensive math once
If you reuse the same longer PromQL expressions in multiple dashboards or alerts, recording rules help.
Example:
groups:
- name: node-recording-rules
rules:
- record: instance:node_cpu_utilisation:avg5m
expr: 100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])))
- record: instance:node_memory_available:percent
expr: 100 * node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
That makes dashboards simpler and can reduce repeated query cost.
I do not create recording rules for everything. Only for queries I know I will reuse heavily.
Node Exporter collectors: do not enable everything blindly
Node Exporter exposes metrics through collectors, and most default collectors are sensible. Still, I pay attention to what is enabled, especially on very large or unusual hosts.
To see active collector behavior quickly:
curl -fsS http://127.0.0.1:9100/metrics | grep '^node_' | head
If I need to disable a noisy or irrelevant collector, I do it explicitly in the service unit. Example:
ExecStart=/usr/local/bin/node_exporter --collector.systemd --no-collector.wifi
The exact collector mix depends on the host role. I do not turn collectors on and off for sport, but I also do not assume defaults are perfect for every workload. Monitoring should be useful, not just verbose.
Service discovery grows up with the environment
Static targets are fine to start with. Once the estate grows, I usually move to some kind of service discovery or generated target file so Prometheus does not become a manual spreadsheet.
A simple file-based discovery example:
scrape_configs:
- job_name: node
file_sd_configs:
- files:
- /etc/prometheus/file_sd/node_targets.yml
And then a target file such as /etc/prometheus/file_sd/node_targets.yml:
- targets:
- 192.0.2.10:9100
- 192.0.2.11:9100
labels:
env: production
I like file-based discovery because it is easy to generate from CMDB data, Ansible, or a small inventory script without pulling in a full orchestration stack on day one.
Alertmanager: where alerts become usable
Prometheus can fire alerts, but Alertmanager is what makes them operationally tolerable.
A minimal Alertmanager config:
global:
resolve_timeout: 5m
route:
receiver: email-default
group_by: ['alertname', 'instance']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: email-default
email_configs:
- to: ops@example.com
from: prometheus@example.com
smarthost: smtp.example.com:587
auth_username: prometheus@example.com
auth_password: ReplaceWithRealPassword
require_tls: true
inhibit_rules:
- source_matchers:
- severity = critical
target_matchers:
- severity = warning
equal:
- alertname
- instance
The inhibition rule above stops warning alerts from duplicating critical alerts for the same condition and instance. I use inhibition a lot because alert floods are how good monitoring gets ignored.
Example alert rules I actually find useful
Disk space warning:
groups:
- name: node-alerts
rules:
- alert: HostDiskSpaceLow
expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs",mountpoint="/"} /
node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs",mountpoint="/"}) < 0.15
for: 15m
labels:
severity: warning
annotations:
summary: Root filesystem low on space
description: "{{ $labels.instance }} has less than 15% free space on /"
CPU usage high:
- alert: HostCPUHigh
expr: 100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) > 90
for: 10m
labels:
severity: warning
annotations:
summary: CPU usage is high
description: "{{ $labels.instance }} CPU usage has been above 90% for 10 minutes"
Memory pressure:
- alert: HostMemoryLow
expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) < 0.10
for: 10m
labels:
severity: warning
annotations:
summary: Available memory is low
description: "{{ $labels.instance }} has less than 10% available memory"
TLS expiry again, with Blackbox Exporter metrics:
- alert: TLSCertificateCritical
expr: (probe_ssl_earliest_cert_expiry - time()) < 7 * 24 * 3600
for: 15m
labels:
severity: critical
annotations:
summary: TLS certificate expires within 7 days
description: "{{ $labels.instance }} certificate expires within 7 days"
I prefer a 30-day warning and a 7-day critical threshold. That gives enough time for normal certificate renewal work without training everyone to ignore expiry warnings.
Retention and storage: decide on purpose, not default
Prometheus local storage is simple, but retention still needs a conscious decision.
Common startup flag:
--storage.tsdb.retention.time=30d
Or size-based retention:
--storage.tsdb.retention.size=50GB
If you do nothing, the default behavior may be acceptable for a lab, but I prefer to define it explicitly so disk usage is predictable.
What influences retention choice:
- number of targets
- scrape interval
- metric cardinality
- whether local Prometheus is the only long-term store
- available disk space
For short-lived infrastructure, 15 to 30 days locally is often enough. For long-term trend analysis, I offload elsewhere.
remote_write for long-term storage
Prometheus local TSDB is not meant to be your forever archive in every environment.
A basic remote_write example:
remote_write:
- url: https://mimir.example.com/api/v1/push
basic_auth:
username: prometheus
password: ReplaceWithRealPassword
Common long-term destinations include systems like Thanos, Cortex, or Mimir. The right choice depends on scale and operational taste.
I do not add remote storage on day one unless I know I need it. But I plan for it early if the environment is growing fast.
Secure Prometheus: it has no auth by default
This is the warning I wish more guides put near the top.
Prometheus does not ship with built-in authentication in the simple sense many admins expect. If you bind it to a public interface and call it a day, you are exposing:
- metric data
- target inventory
- labels that may reveal internal naming
- alert configuration details
That is bad practice.
My defaults:
- bind Prometheus on a private interface when possible
- restrict access with a firewall or reverse proxy
- add authentication in the fronting layer if users need access
- never expose exporters blindly to the public internet
Quick firewall example with UFW:
sudo ufw allow from 192.168.1.0/24 to any port 9090 proto tcp
sudo ufw allow from 192.168.1.0/24 to any port 9100 proto tcp
If you need a stronger baseline host security posture overall, the Linux server hardening checklist is worth applying alongside monitoring.
Grafana integration
Prometheus works without Grafana, but Grafana is still the fastest way to hand usable dashboards to other people.
Basic workflow:
- install Grafana
- add Prometheus as a data source
- point it at
http://prometheus-server:9090 - import a known-good dashboard
For Node Exporter, dashboard 1860 is the usual starting point.
That dashboard is not perfect, but it gets a team from zero to useful visibility very quickly. After that, I trim panels that do not matter and add environment-specific ones.
Troubleshooting checks I run first
When graphs are empty or alerts never fire, I check these before touching anything fancy:
- does the exporter respond with metrics?
- does Prometheus list the target as
UP? - does
promtool check configpass? - is firewall or routing blocking the scrape?
- is the query using the correct metric name and label set?
Useful commands:
curl -fsS http://127.0.0.1:9100/metrics | head
curl -fsS http://127.0.0.1:9090/-/healthy
sudo promtool check config /etc/prometheus/prometheus.yml
sudo journalctl -u prometheus -n 100 --no-pager
sudo journalctl -u node_exporter -n 100 --no-pager
That small set catches most setup mistakes.
Final thoughts
Prometheus plus Node Exporter is still one of the cleanest ways to monitor Linux infrastructure. The model is simple, the data is inspectable, and the tooling around it is mature enough that I can get from a fresh server to useful graphs and alerts quickly.
The practical lessons are not complicated. Keep the initial configuration small. Validate the exporter endpoint before editing PromQL. Use recording rules for repeated expensive queries. Let Alertmanager handle grouping and inhibition so people do not drown in duplicate noise. Monitor TLS expiry with the right exporter instead of forcing Node Exporter to do the wrong job. And do not expose Prometheus or exporters publicly without a protection layer.
Do that, and you end up with monitoring that is boring in the best possible way: dependable, understandable, and easy to troubleshoot when something drifts.