How to Monitor Nginx and PostgreSQL with Munin Plugins

Out of the box, Munin graphs the server: CPU, memory, disk, network. Useful, but when a site slows down the question is usually more specific. Is Nginx suddenly getting twice the requests? Is PostgreSQL running out of connections or reading from disk instead of cache? Munin ships plugins for both, they just need a little wiring before they’ll report anything.

This guide picks up where How to Install Munin on Linux left off. The master is linux1 (Ubuntu 26.04) and the node is app1 (RHEL 9) running Nginx 1.20 and PostgreSQL 16. Every command and graph below is from that setup.

What you need before the plugins will work

The Nginx and PostgreSQL plugins are already on disk with munin-node, in /usr/share/munin/plugins/. They’re Perl scripts with two dependencies people forget about:

  • Nginx plugins fetch a status page over HTTP, so they need LWP: perl-libwww-perl on RHEL, libwww-perl on Ubuntu.
  • PostgreSQL plugins talk to the database through DBI, so they need perl-DBD-Pg on RHEL or libdbd-pg-perl on Ubuntu.

Miss either one and the plugin either refuses to autoconfigure or prints a Perl error instead of numbers. Both were already installed on my node; install them if rpm -q or dpkg -l says otherwise.

The easiest way to see where you stand is to ask munin-node itself. Here’s app1 right after installing Nginx and PostgreSQL:

sudo munin-node-configure --suggest 2>/dev/null | grep -E "^(Plugin|nginx|postgres)"
Plugin                     | Used | Suggestions
nginx_request              | no   | no [no nginx status on http://localhost/nginx_status]
nginx_status               | no   | no [no nginx status on http://localhost/nginx_status]
postgres_autovacuum        | no   | yes
postgres_bgwriter          | no   | yes
postgres_cache_            | no   | yes (+ALL)
postgres_checkpoints       | no   | yes
postgres_connections_      | no   | yes (+ALL)
postgres_connections_db    | no   | yes
postgres_locks_            | no   | yes (+ALL)
postgres_oldest_prepared_xact_ | no   | no [Prepared transactions not enabled]
postgres_prepared_xacts_   | no   | no [Prepared transactions not enabled]
postgres_querylength_      | no   | yes (+ALL)
postgres_scans_            | no   | yes
postgres_size_             | no   | yes (+ALL)
postgres_transactions_     | no   | yes (+ALL)
postgres_tuples_           | no   | yes
postgres_users             | no   | yes
postgres_xlog              | no   | yes

PostgreSQL is ready to go. Nginx says no, and it tells you exactly why: there’s no status page at http://localhost/nginx_status. So that’s the first job.

Monitor Nginx with Munin

Turn on the stub_status page

Both Nginx plugins read the stub_status module. Check that your build has it (distro packages almost always do):

nginx -V 2>&1 | grep -o with-http_stub_status_module
with-http_stub_status_module

On RHEL, the default server block includes /etc/nginx/default.d/*.conf, so I dropped a small file in there. On Ubuntu, put the same location block inside the server block in /etc/nginx/sites-available/default.

cat /etc/nginx/default.d/status.conf
# stub_status for munin-node, local only
location = /nginx_status {
    stub_status;
    access_log off;
    allow 127.0.0.1;
    allow ::1;
    deny all;
}
sudo nginx -t && sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

The allow lines matter. munin-node runs on the same box, so only localhost needs to see this page, and there’s no reason to show your connection counts to the internet. Same request, two different sources:

curl http://localhost/nginx_status
Active connections: 1
server accepts handled requests
 12 12 12
Reading: 0 Writing: 1 Waiting: 0
curl -s -o /dev/null -w "%{http_code}\n" http://192.168.100.65/nginx_status
403

One small thing that confused me for a minute: my first curl right after the reload returned a 404. The old worker processes were still finishing up and answered with the old config. A second later it worked. If you script this, give the reload a moment.

Also keep access_log off. Munin polls this page every five minutes, forever, and you don’t want those hits padding your access logs or your traffic stats.

Enable the two Nginx plugins

Now --suggest changes its answer:

sudo munin-node-configure --suggest 2>/dev/null | grep nginx
nginx_request              | no   | yes
nginx_status               | no   | yes

You get two graphs. nginx_request shows requests per second, and nginx_status shows connections broken down into reading, writing and waiting (keep-alive). If your status page lives at a different URL or port, tell the plugins in /etc/munin/plugin-conf.d/:

[nginx_*]
env.url http://localhost:8080/nginx_status

I didn’t need that, since the default URL matched. I’ll link both plugins in the next section along with the PostgreSQL ones.

Monitor PostgreSQL with Munin

How the plugins log in

The PostgreSQL plugins connect as a database user, and the package already handles that. Both RHEL and Ubuntu ship this block in their default plugin config:

[postgres_*]
user postgres
env.PGUSER postgres
env.PGPORT 5432

The plugin runs as the postgres system user and connects over the local socket, so peer authentication lets it in with no password. If your PostgreSQL runs on another port or host, or you want a dedicated read-only monitoring role, override these in a file of your own, such as /etc/munin/plugin-conf.d/postgres. Don’t edit the packaged file, since upgrades can overwrite it.

Wildcard plugins: _ALL versus a database name

This is the part most guides gloss over. Plugins ending in an underscore are wildcard plugins: the text after the underscore in the symlink name tells them what to watch. postgres_size_ALL graphs every database, while postgres_size_shopdb graphs just one. Two of them, postgres_scans_ and postgres_tuples_, only work per database, which is why --suggest showed them without (+ALL).

So I created a test database and filled it with pgbench data before linking anything:

sudo -u postgres createdb shopdb
sudo -u postgres pgbench -i -s 10 shopdb
creating primary keys...
done in 4.07 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 2.35 s, vacuum 0.29 s, primary keys 1.42 s).

Now let munin-node-configure --shell write the symlink commands for you and pipe them into sh. It picks up the new database on its own:

sudo munin-node-configure --shell | sh
sudo systemctl restart munin-node
ls /etc/munin/plugins | grep -E "nginx|postgres"
nginx_request
nginx_status
postgres_autovacuum
postgres_bgwriter
postgres_cache_ALL
postgres_cache_shopdb
postgres_checkpoints
postgres_connections_ALL
postgres_connections_db
postgres_connections_shopdb
postgres_locks_ALL
postgres_locks_shopdb
postgres_querylength_ALL
postgres_querylength_shopdb
postgres_scans_shopdb
postgres_size_ALL
postgres_size_shopdb
postgres_transactions_ALL
postgres_transactions_shopdb
postgres_tuples_shopdb
postgres_users
postgres_xlog

That’s 20 PostgreSQL graphs plus the 2 Nginx ones. If you add a database later, run munin-node-configure --shell | sh again and it links only what’s new. If 20 is more than you’ll ever look at, just rm the links you don’t want. The ones I actually watch:

  • postgres_connections_ALL: active, idle and “idle in transaction” sessions. A steady climb in idle-in-transaction usually means an app is forgetting to commit.
  • postgres_cache_: blocks read from disk vs found in shared buffers. If reads start to win, the working set has outgrown memory.
  • postgres_size_: database size over weeks and months, which makes it easy to plan disk.
  • postgres_transactions_: commits and rollbacks. A rollback spike usually points to application errors.
  • postgres_locks_ and postgres_checkpoints: worth a look when things slow down for no obvious reason.

About those “role munin does not exist” errors

After all this, the PostgreSQL log had a few lines that look alarming:

sudo grep FATAL /var/lib/pgsql/data/log/*.log | tail -2
2026-10-10 10:00:53.723 PKT [25779] FATAL:  role "munin" does not exist
2026-10-10 10:00:57.647 PKT [25948] FATAL:  role "munin" does not exist

I tracked them down: every run of munin-node-configure adds three of these (the count went from 12 to 15), while munin-run and the master’s regular polling add none. Part of its probing connects as the munin system user instead of postgres, PostgreSQL rejects that role, and the plugins still get detected and linked correctly. It’s harmless. Just don’t create a munin role to make it go away, and don’t mistake it for the real problem if you’re debugging something else.

Test each plugin with munin-run

Before you wait for the master, run each plugin by hand on the node. munin-run runs it exactly as munin-node would, with the same user and environment from plugin-conf.d:

sudo munin-run nginx_request
request.value 32
sudo munin-run nginx_status
total.value 1
reading.value 0
writing.value 1
waiting.value 0
sudo munin-run postgres_size_shopdb
shopdb.value 164944919
sudo munin-run postgres_cache_shopdb
blks_read.value 28776
blks_hit.value 25296
sudo munin-run postgres_transactions_shopdb
commit.value 55
rollback.value 0

Numbers mean it works. Note that request.value is a running total, not a rate; Munin stores it as a counter and turns it into requests per second on the graph. The shopdb size of 164,944,919 bytes is the ~157 MB that pgbench -s 10 creates.

If you get a Perl error, a dependency is missing. If you get nothing for a postgres plugin, check that the database name in the symlink actually exists. Then check the whole path from the master, the same way the master will poll it:

asif@linux1:~$ printf "fetch nginx_request\nquit\n" | nc 192.168.100.65 4949
# munin node at app1
request.value 176
.

Nothing to change on the master. New plugins on a node are picked up automatically on the next five-minute run, and the graphs appear under the webserver and db categories.

Setup confirmation

To give the graphs something to show, I ran a light load for about 45 minutes: a curl loop against Nginx and pgbench -c 4 -R 40 shopdb, which holds PostgreSQL at roughly 40 transactions a second. Here’s the check, starting on the node:

systemctl is-active nginx postgresql munin-node
active
active
active

On the master, all 22 new plugins (2 Nginx, 20 PostgreSQL) have their own RRD files, and the five-minute runs are finishing cleanly:

ls /var/lib/munin/Lab/ | grep -E "app1-(nginx|postgres)" | cut -d- -f2 | sort -u | wc -l
22
grep "Munin-update finished (" /var/log/munin/munin-update.log | tail -2
2026/10/10 10:30:20 [INFO]: Munin-update finished (18.46 sec)
2026/10/10 10:35:19 [INFO]: Munin-update finished (17.38 sec)

In the web interface, app1 now has a db category listing every PostgreSQL graph, both the server-wide ones and the per-database shopdb ones, plus a webserver category for Nginx:

Munin node page for app1 listing PostgreSQL graphs in the db category

A tip for brand-new graphs: the “by day” view covers about 30 hours, so 45 minutes of data is a thin sliver on the right edge. Click any graph to open Munin’s zoom page and pick a shorter range. Zoom needs CGI enabled in Apache (sudo a2enmod cgi on Ubuntu; mine already had it). These are the last 45 minutes.

Nginx held steady at 20 to 22 requests a second, which matches the curl loop:

Munin Nginx requests per second graph holding around 21 requests per second

PostgreSQL transactions on shopdb sit right at 40 per second with zero rollbacks, exactly what -R 40 asked for:

Munin PostgreSQL transactions graph for shopdb showing about 40 commits per second

The buffer cache graph is the one I’d keep an eye on in production. About 690 blocks a second are served from memory against roughly 10 read from disk, around a 98.6% hit rate. If the green line starts climbing toward the blue one, the database has outgrown shared_buffers or RAM.

Munin PostgreSQL buffer cache graph showing buffers hit far above buffers read

And one that taught me something. The connections graph shows all four pgbench clients as idle, never active:

Munin PostgreSQL connections graph showing four idle connections and zero active

That’s not a broken plugin. Each transaction takes a few milliseconds, and Munin samples once every five minutes, so the odds of catching a session mid-query are tiny. A five-minute snapshot is great for spotting creeping trends (idle connections piling up, idle-in-transaction growing) and poor at catching short spikes. Keep that in mind before you blame the database for a graph that looks quiet.

Frequently asked questions

Can Munin monitor Nginx without stub_status?

Not with the stock plugins. Both read that status page. If you need per-status-code or per-vhost numbers, a log-based plugin from the Munin contrib repo can parse the access log instead, but for request rate and connections, stub_status is the lightest option there is.

Why does the Nginx request graph show a rate when munin-run shows a big number?

The plugin reports the total requests since Nginx started, and Munin stores it as a DERIVE value. The master works out the difference between polls and plots requests per second. Restarting Nginx resets the counter, and the plugin sets request.min 0, so Munin skips that one sample instead of drawing a huge negative dip.

Do I need a separate PostgreSQL user for Munin?

No, the default config connects as postgres over the local socket. On a shared or locked-down server, a dedicated role with the built-in pg_monitor privilege is cleaner. Point env.PGUSER at it in your own plugin config file.

Does this work with PostgreSQL on another server or in Docker?

Yes. Set env.PGHOST and env.PGPORT (plus a password via env.PGPASSWORD or a .pgpass file) and the plugins connect over TCP. Simpler still, install munin-node on the database server itself and let the master poll it like any other node.

How do I monitor MySQL or Apache the same way?

Same pattern. Apache uses mod_status with the apache_* plugins, which Ubuntu enabled automatically on my master. MySQL uses the mysql_ wildcard plugin. Check munin-node-configure --suggest and it’ll tell you what each one is missing.

Asif Khan, author of LinuxPathfinder.com

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.