The stock telegraf.conf that Ubuntu installs watches the host and nothing else. CPU, memory, disks, swap, kernel counters. All of it useful, none of it aware that the box is also running containers, serving HTTP, and committing transactions. The moment someone asks why the site got slow at 14:20, host graphs alone will not answer.
This guide adds the three Telegraf input plugins that close that gap on a typical Linux server: inputs.docker, inputs.nginx, and inputs.postgresql. Each one is a short drop-in file, each one has a permission problem the first time you start it, and one of them will take the whole agent down if you get it wrong. The errors below are the ones the agent actually printed on the test box, not a guess at what might happen.
Ran this end to end on Ubuntu 26.04 and 24.04 in September 2026, same Telegraf build on both.
What you need before starting
A working Telegraf agent already shipping to a time series database. If you do not have one yet, the InfluxDB, Telegraf and Grafana install guide builds exactly that stack, and this article picks up where it stops. Everything here also applies to a Telegraf writing into Prometheus, Graphite, or an older InfluxDB, because input plugins do not care what the output plugin is.
You also need the three services worth measuring. The install steps are out of scope here, so if any of them is missing, work through Docker CE on Ubuntu, the Nginx setup with Let’s Encrypt, or the PostgreSQL install first and come back.
Sizing is not really a question for the agent itself. Fourteen minutes into a run with twelve plugins loaded, the test box reported 45.8 MB resident and 3.3 seconds of CPU consumed in total. What actually costs you is the number of series the plugins produce, and that lands on the database rather than on the agent. It is covered at the end.
1. Where Telegraf input plugin configuration belongs
The package ships one enormous /etc/telegraf/telegraf.conf with several thousand commented lines. Editing it works, and it is also the reason so many Telegraf setups are impossible to diff. Every plugin in this guide goes into its own file under /etc/telegraf/telegraf.d/ instead, which the systemd unit already loads:
systemctl cat telegraf | grep ExecStart
The -config-directory flag is what makes drop-ins work, and it is on by default:
ExecStart=/usr/bin/telegraf -config /etc/telegraf/telegraf.conf -config-directory /etc/telegraf/telegraf.d $TELEGRAF_OPTS
Check what the stock config already gives you before adding anything, otherwise you end up with two definitions of the same plugin quietly doubling your write volume:
grep -E '^\[\[inputs' /etc/telegraf/telegraf.conf
Eight plugins, every one of them host level:
[[inputs.cpu]]
[[inputs.disk]]
[[inputs.diskio]]
[[inputs.kernel]]
[[inputs.mem]]
[[inputs.processes]]
[[inputs.swap]]
[[inputs.system]]
One thing about file permissions before you write anything into that directory. Telegraf reads its config as the telegraf user, not as root, so a drop-in that root owns with mode 600 stops the agent dead. This trips people the moment they put a database password in a file and try to lock it down. The correct way to protect a drop-in is to hand it to the service account:
sudo chown telegraf:telegraf /etc/telegraf/telegraf.d/somefile.conf
sudo chmod 640 /etc/telegraf/telegraf.d/somefile.conf
Get that wrong and the failure is total rather than partial, which is the theme of this whole article.
Error: “loading config file … permission denied”
A root-owned mode 600 drop-in produces this on every start attempt, and the agent never gets as far as loading plugins:
E! loading config file /etc/telegraf/telegraf.d/postgresql.conf failed: open /etc/telegraf/telegraf.d/postgresql.conf: permission denied
Either apply the chown above, or better, keep the secret out of the file entirely. Telegraf expands environment variables from /etc/default/telegraf into any config value, which is the pattern used for the database password in step 5.
2. Fill the gaps in the stock system metrics
Network counters are missing from that list of eight, which is a strange default for a monitoring agent. Add them:
sudo vim /etc/telegraf/telegraf.d/net.conf
Naming the interfaces you care about keeps loopback, bridges, and every transient veth pair a container creates out of the database:
[[inputs.net]]
interfaces = ["ens*", "eth*", "en*"]
Skip that interfaces line on a Docker host and every container you start adds another network series that dies again when the container does. That is the fastest way to build a high cardinality problem out of nothing.
Almost every Telegraf tutorial online also sets ignore_protocol_stats = true in this plugin. Do not copy it. The agent will tell you why:
telegraf --config /etc/telegraf/telegraf.conf --config-directory /etc/telegraf/telegraf.d --deprecation-list | grep inputs.net/
The option still parses, and it no longer does anything:
inputs.net/ignore_protocol_stats WARN since 1.37.0 removal in 1.45.0 option is ignored
Run that --deprecation-list check against your own config any time you inherit a Telegraf setup from someone else. It reads the config you point it at and reports only what you are actually using, so it is a much faster audit than reading changelogs. The count also shows up at every start as a line like W! Deprecated inputs: 0 and 1 options, which is easy to scroll past and worth grepping for.
3. Add the Docker input plugin
The Docker plugin talks to the daemon socket and turns every running container into five separate measurements. Create the drop-in:
sudo vim /etc/telegraf/telegraf.d/docker.conf
Every line below is the plugin’s own shipped default, written out instead of left commented. That is deliberate rather than lazy: these are the five settings you will reach for first, and a config that states its defaults is a config you can diff a year from now.
[[inputs.docker]]
endpoint = "unix:///var/run/docker.sock"
container_state_include = ["running"]
perdevice_include = ["cpu"]
total_include = ["cpu", "blkio", "network"]
timeout = "5s"
One of those defaults does nothing at all on a current Ubuntu host, and it is worth knowing which. perdevice_include asks the plugin for a row per CPU, but per-CPU numbers only exist in the Docker stats API under cgroup v1, and both current LTS releases run cgroup v2 with the systemd driver. Ask the socket yourself, swapping in any running container name from docker ps --format '{{.Names}}':
sudo curl -s --unix-socket /var/run/docker.sock \
'http://localhost/containers/web-demo/stats?stream=false' \
| jq '.cpu_stats.cpu_usage | keys'
Three keys, and no percpu_usage among them:
[
"total_usage",
"usage_in_kernelmode",
"usage_in_usermode"
]
So docker_container_cpu arrives as exactly one row per container carrying cpu-total, no matter what you put in perdevice_include. The option that earns its place in that block is total_include, which decides whether you get the accumulated blkio and network rows at all.
Restart the agent and it will fail. That is expected, and the way it fails is worth watching:
sudo systemctl restart telegraf
sudo journalctl -u telegraf -n 20 --no-pager
The journal explains itself in five lines, and they are worth reading in order.
Error: “permission denied while trying to connect to the docker API”
The agent runs as telegraf, and /var/run/docker.sock is owned by root:docker:
E! [telegraf] Error running agent: starting input inputs.docker: failed to ping daemon: permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
systemd[1]: telegraf.service: Main process exited, code=exited, status=1/FAILURE
systemd[1]: telegraf.service: Scheduled restart job, restart counter is at 5.
systemd[1]: telegraf.service: Start request repeated too quickly.
systemd[1]: telegraf.service: Failed with result 'exit-code'.
Read those five lines carefully, because they describe the single most important behaviour in this article. The Docker plugin failed while starting, so Telegraf aborted the whole agent rather than skipping one plugin. CPU, memory and disk collection stopped too. systemd then retried five times inside its rate limit window and gave up, leaving the unit in failed. A monitoring agent that silently stops monitoring is worse than one that never started, and this is how it happens.
Add the service account to the docker group:
sudo usermod -aG docker telegraf
id telegraf
The secondary group has to be there before the next start, since the process picks up its groups at exec time:
uid=997(telegraf) gid=982(telegraf) groups=982(telegraf),981(docker)
If the unit already burned through its restart budget, systemd refuses further start attempts until the window expires. Clear it and start clean:
sudo systemctl reset-failed telegraf
sudo systemctl restart telegraf
Adding telegraf to the docker group is effectively giving that account root on the host, because anyone who can talk to the socket can start a privileged container. On a machine where that matters, put a filtering proxy such as Tecnativa’s docker-socket-proxy in front of the daemon and give Telegraf a read-only endpoint over TCP instead. On a single application server it is a trade most people make knowingly.
4. Add the Nginx input plugin
Nginx does not expose anything by default. The stub_status module is compiled into the Ubuntu package, but no site enables it, so the first job is a status endpoint bound to loopback where nothing outside the box can reach it:
sudo vim /etc/nginx/conf.d/status.conf
Keep it on its own port so it never collides with a real virtual host and never leaks into a public server block:
server {
listen 127.0.0.1:8080;
server_name localhost;
location /basic_status {
stub_status;
}
}
Test the syntax before reloading, every time:
sudo nginx -t
sudo systemctl reload nginx
curl http://127.0.0.1:8080/basic_status
Seven numbers, which is the entire surface the module offers:
Active connections: 1
server accepts handled requests
8 8 8
Reading: 0 Writing: 1 Waiting: 0
Now point the plugin at it:
sudo vim /etc/telegraf/telegraf.d/nginx.conf
The URL has to match the location block exactly, path included:
[[inputs.nginx]]
urls = ["http://127.0.0.1:8080/basic_status"]
response_timeout = "5s"
Bounce the service and give the journal a couple of collection intervals to say something.
Error: “connection refused” and “returned HTTP status 404 Not Found”
These two cover almost every broken Nginx input. The first means nothing is listening on that address and port, usually because the status server block was never reloaded:
E! [inputs.nginx] Error in plugin: error making HTTP request to "http://127.0.0.1:8080/basic_status": Get "http://127.0.0.1:8080/basic_status": dial tcp 127.0.0.1:8080: connect: connection refused
The second means Nginx answered but the path is wrong, which happens whenever the config says /basic_status and the plugin was copied from a tutorial using /nginx_status:
E! [inputs.nginx] Error in plugin: http://127.0.0.1:8080/basic_status returned HTTP status 404 Not Found
Notice what did not happen either time. The agent stayed up, kept collecting every other plugin, and logged one line per interval. A plugin that fails during Gather is retried forever; a plugin that fails during Start kills the process. That distinction is why the Docker mistake is expensive and this one is merely noisy, and it is worth knowing which of your plugins sits on which side.
One detail that matters if you are feeding InfluxDB: this plugin emits unsigned integers, the u suffix in line protocol. That is exactly the type that breaks an InfluxDB 2 to 3 migration when historical data was written as signed. New databases are fine; databases carrying converted history are not.
5. Add the PostgreSQL input plugin
PostgreSQL needs a role before it needs a plugin. The password shows up in three places, so export it once and reuse it for the rest of the session:
export PG_MON_PASS="ChangeMe#Strong2026"
Create a login role that owns nothing and can write nothing:
sudo -u postgres psql -c "CREATE ROLE telegraf WITH LOGIN PASSWORD '${PG_MON_PASS}';"
Put the password in the environment file the systemd unit already sources, not in the plugin config:
echo "PG_PASSWORD=${PG_MON_PASS}" | sudo tee -a /etc/default/telegraf
Then write the drop-in, which never sees the secret itself:
sudo vim /etc/telegraf/telegraf.d/postgresql.conf
Telegraf expands ${PG_PASSWORD} from the environment when it parses the file, so the config stays world readable and safe to commit:
[[inputs.postgresql]]
address = "host=localhost user=telegraf password=${PG_PASSWORD} dbname=postgres sslmode=disable"
ignored_databases = ["template0", "template1"]
With the role in place the plugin connects on the next start. Reach for the superuser instead and it will not.
Error: “password authentication failed for user postgres (SQLSTATE 28P01)”
This is what you get by pasting the plugin’s own sample config, which ships with user=postgres and no password. The postgres superuser on a Debian or Ubuntu package install has no password set at all, because it authenticates by peer over the local socket:
E! [inputs.postgresql] Error in plugin: failed to connect to `user=postgres database=`: 127.0.0.1:5432 (localhost): failed SASL auth: FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01)
A dedicated role with a real password fixes it. Do not be tempted to hand the superuser a password just to make the sample config work.
Does the monitoring role need pg_monitor?
No, not for this plugin. Nearly every guide tells you to grant it anyway, and on the test box the role collected every field without it. The reason is simple: inputs.postgresql reads pg_stat_database and pg_stat_bgwriter, and both are readable by any login role.
pg_monitor starts to matter the moment you extend into per session data, and the way it fails without the grant is easy to misread. Sessions belonging to other users stay visible, so a connection count is fine; what gets withheld is most of the columns. Run this while a postgres session is mid-query:
PGPASSWORD="${PG_MON_PASS}" psql -h localhost -U telegraf -d postgres -Atc \
"SELECT usename, backend_type, query FROM pg_stat_activity WHERE usename='postgres';"
The rows come back, the owner is named, and the two interesting columns do not survive:
postgres||<insufficient privilege>
postgres||<insufficient privilege>
query is replaced by the literal string <insufficient privilege>, and backend_type comes back empty. That empty column is the part that bites, because a WHERE clause on backend_type = 'client backend' then silently matches nothing and the query looks like it found no sessions rather than no permission. Grant the role and run the identical statement:
sudo -u postgres psql -c "GRANT pg_monitor TO telegraf;"
Both columns fill in, including the SQL text:
postgres|client backend|SELECT pg_sleep(30);
postgres|logical replication launcher|
Pushing from Telegraf is one of two shapes this can take. If your estate is already Prometheus-first, scraping PostgreSQL with an exporter covers the same ground by pulling instead of pushing, and the role privileges below apply either way.
So the rule is: bare login role for inputs.postgresql, pg_monitor once you move to inputs.postgresql_extensible with custom queries against pg_stat_activity, replication, or pg_stat_statements. Granting it early costs nothing much, but knowing which half you actually need saves an argument with whoever owns the database.
One cosmetic thing worth fixing now rather than after you have dashboards. By default the server tag is the whole connection string with the password stripped, which makes for ugly legends and breaks the moment you edit the DSN. Pin it instead with outputaddress = "db01" in the same block.
6. Test one plugin without restarting the agent
Restarting Telegraf to check a config change is a bad habit, because a syntax error takes down every other plugin with it. Run one gather cycle for a single plugin and print the result instead:
sudo bash -c 'set -a; . /etc/default/telegraf; set +a; \
telegraf --config /etc/telegraf/telegraf.conf \
--config-directory /etc/telegraf/telegraf.d --test --input-filter nginx'
Sourcing the environment file first is the part people miss, and without it every ${VAR} in your configs expands to nothing. The output is raw line protocol, one line per point:
> nginx,host=monitor01,port=8080,server=127.0.0.1 accepts=112u,active=2u,handled=112u,reading=0u,requests=156u,waiting=1u,writing=1u 1788681799000000000
Drop --input-filter and you get every plugin at once, which is the cheapest cardinality estimate available. Counting the lines tells you exactly what one collection interval costs:
sudo bash -c 'set -a; . /etc/default/telegraf; set +a; \
telegraf --config /etc/telegraf/telegraf.conf \
--config-directory /etc/telegraf/telegraf.d --test' \
| awk '{print $2}' | cut -d, -f1 | sort | uniq -c | sort -rn
Twelve plugins on a four vCPU box with two containers, one Nginx and one PostgreSQL produced 42 points every ten seconds:
7 diskio
5 cpu
4 net
3 system
3 disk
2 swap
2 postgresql
2 docker_container_status
2 docker_container_net
2 docker_container_mem
2 docker_container_cpu
2 docker_container_blkio
2 docker
1 processes
1 nginx
1 mem
1 kernel
Five of those measurements are per container, so the arithmetic for capacity planning is easy: every running container adds five points per interval, forever, whether or not anyone looks at them. Fifty containers is 250 points every ten seconds from this one plugin.
With all four drop-ins in place the agent should report twelve inputs and stay running:

Twelve inputs, one output, and nothing but informational lines in the journal. That is the state to reach before opening Grafana.
7. Chart the new measurements in Grafana
Each plugin lands as its own measurement, so the first thing to do is confirm the tables exist before building panels against names you assumed. Anything below assumes Grafana is already talking to the database; if it is not, start with the Grafana install and datasource setup. Against InfluxDB 3 that is a plain SQL catalogue query:
influxdb3 query --database system_metrics --token "$INFLUX_TOKEN" 'SHOW TABLES'
One Docker plugin produced six of them, which surprises people expecting a single docker table:
docker
docker_container_blkio
docker_container_cpu
docker_container_mem
docker_container_net
docker_container_status
Grafana panels then read like ordinary SQL. Splitting by container_name gives one line per container without any per container configuration:
SELECT time, container_name, usage
FROM docker_container_mem
WHERE $__timeFilter(time)
ORDER BY time
Four panels covering the three new plugins, on a dashboard built entirely from the queries above:

The container panels are the ones that pay for themselves fastest. Memory per container is the graph that ends the “which container ate the box” conversation, and it needs no exporter, no sidecar, and no change to the containers themselves:

On the PostgreSQL side, remember that xact_commit and friends are cumulative counters, not rates. A raw counter climbing in a straight line is correct and nearly useless; wrap it in a delta or a derivative before anyone alerts on it:

What arrives from PostgreSQL depends on the server version, and the difference is not trivial. The same plugin, the same config, on the two current Ubuntu LTS releases:
| Item | Ubuntu 26.04 | Ubuntu 24.04 |
|---|---|---|
| Docker repo component | resolute | noble |
| Nginx package | 1.28.3 | 1.24.0 |
| PostgreSQL package | 18.6 | 16.15 |
data_checksums default | on | off |
Fields on the postgresql measurement | 28 | 25 |
| Telegraf package | 1.39.3 | 1.39.3 |
| Plugin config, paths, service account | identical | |
The three extra fields on the newer release are checksum_failures, parallel_workers_launched, and parallel_workers_to_launch. The checksum one is the interesting case: it exists on both, but Telegraf drops NULL fields, and data checksums are off by default on the older PostgreSQL and on by default on the newer one. So a dashboard panel for corrupted pages is silently empty on one host and populated on the other, with nothing in the logs to explain it.
What I would change before running this in production
The lab config above is deliberately plain so each plugin can be read in isolation. Four things I would not ship as written.
Container labels become tags, all of them. With docker_label_include left empty the plugin promotes every label to a tag, which is how maintainer=NGINX Docker Maintainers ended up on every measurement from the test web container. On a Kubernetes node, where pods carry a dozen labels apiece and some of them contain the pod hash, that is a cardinality explosion with a slow fuse. Name the two or three labels you actually group by and ignore the rest.
One interval does not fit all plugins. The agent default of ten seconds is right for CPU and memory and wasteful for a database whose counters barely move. Each plugin block accepts its own interval, so give inputs.postgresql sixty seconds and leave the host plugins alone. On the numbers above that takes one plugin from twelve points a minute to two. The knob earns far more on a container-dense host, where the Docker plugin is the one writing five points per container per interval.
Ship one drop-in per role, not one config for the fleet. A database server has no Docker daemon and a container host has no PostgreSQL. Because telegraf.d is just a directory, the natural unit of configuration management is the file, and pushing postgresql.conf only to database hosts is cleaner than one giant conditional template.
Watch the watcher. Add [[inputs.internal]]. It publishes an internal_gather measurement tagged per plugin, carrying gather_errors and startup_errors, which is precisely the Gather versus Start split this article keeps coming back to. Every failure mode above, apart from the Docker one that killed the process outright, is invisible unless somebody reads the journal. An agent quietly logging a plugin error every ten seconds for three weeks is the ordinary way monitoring rots, and one alert rule on that counter prevents it.