Monitoring nftables Firewall Rules with Prometheus and Grafana

7 min read

nftables already counts packets and bytes for you. The frustrating part is that it keeps that data trapped inside nft list ruleset, tied to handle numbers that are useless unless you are staring at the exact ruleset on the exact machine at the exact moment something went wrong.

That is fine for one-off debugging. It is terrible for operations.

If you run your own router or firewall, you eventually want the same thing you want everywhere else: time-series data, alerting, dashboards, and enough context to tell the difference between “the WAN is noisy tonight” and “something is hammering the guest network and getting dropped exactly as intended.”

The trick that makes this workable is surprisingly small: put a comment on every nftables rule, then export that comment as a Prometheus label.

Once you do that, you stop looking at this:

nftables_rule_packets{table="filter",chain="input",handle="15"} 48291

and start looking at this:

nftables_rule_packets{table="filter",chain="input",handle="15",comment="wan drop",action="drop"} 48291

That one extra label turns an opaque counter into something you can reason about at a glance.

Why handle numbers are the wrong abstraction

nftables exposes counters per rule with the counter keyword. The data is there, but it is buried in the live ruleset:

sudo nft list ruleset

That gives you a snapshot, not a history. You cannot ask what your drop rate looked like over the last 24 hours. You cannot correlate a spike in blocked packets with a WireGuard handshake failure, a Prometheus alert, or some guest VLAN misbehavior. And if your dashboards key off handle numbers, a rebuild or ruleset reload can make them meaningless.

Comments solve the identity problem.

Handles are implementation details. Comments are intent.

Comments as labels

The rule here is simple: every rule that matters gets both a counter and a comment.

I keep the comments short, lowercase, and boring on purpose. Two to four words is usually enough. The pattern is generally <zone/interface> <action/purpose>:

# Format: "<scope> <function>" or "<description>"
ct state invalid counter drop comment "invalid state"
ip saddr 10.0.0.0/8 tcp dport 53 counter accept comment "lan dns tcp"
ip saddr 10.0.0.0/8 udp dport 53 counter accept comment "lan dns udp"
ip saddr 10.0.0.0/8 ip protocol icmp counter accept comment "lan icmp"
iifname { "ppp0", "mobile" } icmp type echo-request limit rate 5/second burst 10 packets counter accept comment "wan ping ratelimit"
iifname { "ppp0", "mobile" } ip protocol icmp counter drop comment "wan icmp drop"
ip saddr 0.0.0.0/0 udp dport 51820 counter accept comment "wireguard"
iifname { "wifi", "wired", "mgnt", "enp1s0f1", "lo", "wg0" } counter accept comment "trusted ifaces"
iifname "guest" udp dport { 53, 67, 68 } counter accept comment "guest dns+dhcp"
iifname "guest" counter drop comment "guest drop"
iifname "camera" counter drop comment "camera drop"
iifname "ppp0" ct state { established, related } counter accept comment "wan established"
iifname "ppp0" counter drop comment "wan drop"
log prefix "nft-input-drop: " counter drop comment "default drop"

The useful properties of this convention are:

  • Every rule you care about is observable.
  • The label is stable even when nftables handle numbers change.
  • The label is human-readable in Grafana legends and PromQL results.
  • The label lines up with your mental model of the firewall instead of nftables internals.

The last point matters more than it sounds. “wan drop” tells you what the rule is for. “handle 15” tells you where to start digging.

A complete input chain with comments

This is what the pattern looks like in practice on a router with trusted interfaces, guest isolation, a camera network, and WireGuard:

chain input {
  type filter hook input priority filter; policy drop;
  ct state invalid counter drop comment "invalid state"
  ip saddr 10.0.0.0/8 tcp dport 53 counter accept comment "lan dns tcp"
  ip saddr 10.0.0.0/8 udp dport 53 counter accept comment "lan dns udp"
  ip saddr 10.0.0.0/8 ip protocol icmp counter accept comment "lan icmp"
  iifname { "ppp0", "mobile" } icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-reply } counter accept comment "wan icmp replies"
  iifname { "ppp0", "mobile" } icmp type echo-request limit rate 5/second burst 10 packets counter accept comment "wan ping ratelimit"
  iifname { "ppp0", "mobile" } ip protocol icmp counter drop comment "wan icmp drop"
  ip saddr 0.0.0.0/0 udp dport 51820 counter accept comment "wireguard"
  iifname { "wifi", "wired", "mgnt", "enp1s0f1", "lo", "wg0" } counter accept comment "trusted ifaces"
  iifname "guest" udp dport { 53, 67, 68 } counter accept comment "guest dns+dhcp"
  iifname "guest" tcp dport 53 counter accept comment "guest dns tcp"
  iifname "guest" ct state { established, related } counter accept comment "guest established"
  iifname "guest" counter drop comment "guest drop"
  iifname "camera" udp dport { 53, 67, 68 } counter accept comment "camera dns+dhcp"
  iifname "camera" tcp dport 53 counter accept comment "camera dns tcp"
  iifname "camera" ct state { established, related } counter accept comment "camera established"
  iifname "camera" counter drop comment "camera drop"
  ip protocol igmp counter accept comment "igmp"
  ip saddr 224.0.0.0/4 counter accept comment "multicast"
  iifname "ppp0" ct state { established, related } counter accept comment "wan established"
  iifname "ppp0" counter drop comment "wan drop"
  iifname "mobile" ct state { established, related } counter accept comment "mobile established"
  iifname "mobile" counter drop comment "mobile drop"
  log prefix "nft-input-drop: " counter drop comment "default drop"
}

There are two practical habits doing most of the work here:

  1. Every terminal rule has a counter.
  2. Every terminal rule has a comment that explains intent, not syntax.

That is what makes the exporter and the dashboard useful later.

Forward and NAT chains

The same comment pattern carries over cleanly into forwarding and NAT:

chain forward {
  meta oiftype ppp tcp flags syn tcp option maxseg size set 1452 counter comment "ppp mss clamp"
  type filter hook forward priority filter; policy drop;
  ip protocol { tcp, udp } ct state established ct status ! dnat flow add @fastnat counter comment "flow offload non-dnat"
  ct state invalid counter drop comment "invalid state"
  iifname { "guest", "wifi", "wired", "mgnt", "enp1s0f1", "wg0" } oifname { "ppp0", "enp1s0f1", "mobile" } counter accept comment "lan to internet"
  iifname "camera" ip saddr 10.255.200.3 oifname { "ppp0", "enp1s0f1", "mobile" } counter accept comment "unvr to internet"
  iifname { "ppp0", "enp1s0f1", "mobile" } oifname { "wifi", "wired", "mgnt", "guest", "camera", "wg0" } ct state established,related counter accept comment "wan return"
  iifname { "wifi", "wired", "mgnt", "enp1s0f1", "wg0" } oifname { "wifi", "wired", "mgnt", "guest", "camera", "wg0" } counter accept comment "inter-vlan"
  iifname { "wifi", "wired", "mgnt", "wg0" } oifname "camera" counter accept comment "lan to camera"
  iifname "camera" oifname { "wifi", "wired", "mgnt", "wg0" } ct state established,related counter accept comment "camera return"
  iifname { "guest", "camera" } oifname "wired" ip daddr 10.255.101.202 udp dport 123 counter accept comment "guest+camera ntp"
  log prefix "nft-forward-drop: " counter drop comment "default drop"
}
table ip nat {
  chain postrouting {
    type nat hook postrouting priority filter; policy accept;
    oifname "ppp0" counter masquerade comment "ppp0 masquerade"
    oifname "enp1s0f1" counter masquerade comment "vlan masquerade"
    oifname "mobile" counter masquerade comment "mobile masquerade"
    iifname "wired" oifname "wired" ct status dnat counter masquerade comment "hairpin nat"
  }
}

Once you commit to this pattern, the ruleset reads better even before Prometheus gets involved.

Exporting nftables to Prometheus

The exporter on this router is a custom NixOS module, prometheus-nftables-exporter, bundled in my ijohanne/nur-packages repository. It runs locally, executes nft -j list ruleset, parses the JSON, and emits Prometheus metrics for rule counters plus a small amount of table and chain structure data.

If you want to pull it into a NixOS configuration, the short version is to add the repo as a flake input and import the module:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    ijohanne-nur.url = "github:ijohanne/nur-packages";
  };

  outputs = { nixpkgs, ijohanne-nur, ... }: {
    nixosConfigurations.router = nixpkgs.lib.nixosSystem {
      modules = [
        ijohanne-nur.nixosModules.prometheus-nftables-exporter
        ./configuration.nix
      ];
    };
  };
}

Once the module is imported, the service config stays small:

The NixOS configuration is intentionally small:

{ ... }:

{
  services.prometheus-nftables-exporter = {
    enable = true;
    enableLocalScraping = true;
  };
}

enableLocalScraping = true is the nice part. The router’s local Prometheus instance picks the exporter up automatically, so there is no extra scrape stanza to maintain somewhere else.

The exporter loop is straightforward:

  1. Run nft -j list ruleset.
  2. Walk the tables, chains, and rules.
  3. Extract packet counters, byte counters, handle, action, table, chain, family, and comment.
  4. Expose those fields as Prometheus metrics.

Metrics and labels

The rule-level metrics look like this:

MetricTypeLabelsDescription
nftables_rule_packetscountertable, chain, family, handle, action, commentPackets matched by the rule
nftables_rule_bytescountertable, chain, family, handle, action, commentBytes matched by the rule

And the structural metrics look like this:

MetricTypeLabelsDescription
nftables_table_chainsgaugetable, familyNumber of chains in each table
nftables_chain_rulesgaugetable, chain, familyNumber of rules in each chain
nftables_upgaugenoneWhether the exporter is healthy

An example sample line:

nftables_rule_packets{table="filter",chain="input",family="ip",handle="15",action="drop",comment="wan drop"} 48291

This is exactly why the comment label matters. Even if handle 15 becomes 24 after a rebuild, the thing you actually care about is still “wan drop”.

The first PromQL queries that matter

Once the metrics exist, the first few queries are obvious and immediately useful:

# Rate of dropped packets per rule
rate(nftables_rule_packets{instance="$instance",action="drop"}[$__rate_interval])

# Input chain rules, only active series
rate(nftables_rule_packets{instance="$instance",chain="input",table="filter"}[$__rate_interval]) > 0

# Top 10 busiest rules by byte rate
topk(10, rate(nftables_rule_bytes{instance="$instance"}[$__rate_interval]))

# Total traffic across all counted rules
sum(rate(nftables_rule_bytes{instance="$instance"}[$__rate_interval]))

The key design choice is to query and display by comment, not by handle. Handles can still be useful for debugging against a live ruleset, but the dashboard identity should be based on names that survive rebuilds.

Building a Grafana dashboard that answers real questions

The most important firewall panels are not the prettiest ones. They are the ones that answer:

  • What is getting dropped right now?
  • Which chain is busy?
  • Is the guest or camera network doing something unusual?
  • Did a ruleset change remove an expected counter?

That is why the first row I care about is drops and rejects, not total traffic.

The dashboard layout I ended up with looks like this:

Overview

  • Exporter status
  • Table count
  • Chain count
  • Rule count
  • Aggregate rule traffic

Drops and rejects

  • Dropped packets by rule
  • Dropped bytes by rule
  • An all-drops view broken out by chain

This is the panel group I watch first during scans, misconfigurations, or sudden noise on the WAN.

Chain-specific rows

  • Input packets and bytes by rule
  • Forward packets and bytes by rule
  • NAT prerouting and postrouting packets by rule
  • IPv6 input and forward packets by rule

Top rules and structure

  • Top 10 rules by bytes
  • Top 10 rules by packets
  • Table and chain structure tables

The template variables are minimal:

  • $datasource for the Prometheus source
  • $instance from label_values(nftables_up, instance)

Complete dashboard JSON

Import this via Grafana’s dashboard import UI, or provision it as a file:

{
  "annotations": {
    "list": []
  },
  "editable": true,
  "fiscalYearStartMonth": 0,
  "graphTooltip": 1,
  "links": [],
  "panels": [
    {
      "collapsed": false,
      "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
      "id": 100,
      "title": "Overview",
      "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": {
        "defaults": {
          "mappings": [{ "options": { "0": { "text": "Down", "color": "red" }, "1": { "text": "Up", "color": "green" } }, "type": "value" }],
          "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
        }
      },
      "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 },
      "id": 1,
      "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" },
      "title": "Exporter Status",
      "type": "stat",
      "targets": [{ "expr": "nftables_up{instance=\"$instance\"}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } } },
      "gridPos": { "h": 4, "w": 5, "x": 4, "y": 1 },
      "id": 2,
      "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" },
      "title": "Tables",
      "type": "stat",
      "targets": [{ "expr": "count(nftables_table_chains{instance=\"$instance\"})", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } } },
      "gridPos": { "h": 4, "w": 5, "x": 9, "y": 1 },
      "id": 3,
      "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" },
      "title": "Chains",
      "type": "stat",
      "targets": [{ "expr": "sum(nftables_table_chains{instance=\"$instance\"})", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } } },
      "gridPos": { "h": 4, "w": 5, "x": 14, "y": 1 },
      "id": 4,
      "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" },
      "title": "Rules",
      "type": "stat",
      "targets": [{ "expr": "sum(nftables_chain_rules{instance=\"$instance\"})", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "unit": "Bps", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } },
      "gridPos": { "h": 4, "w": 5, "x": 19, "y": 1 },
      "id": 5,
      "options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" },
      "title": "Total Rule Traffic",
      "type": "stat",
      "targets": [{ "expr": "sum(rate(nftables_rule_bytes{instance=\"$instance\"}[$__rate_interval]))", "refId": "A" }]
    },
    {
      "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 101, "title": "Drops & Rejects", "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
      "id": 10,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "Dropped Packets (rate)",
      "description": "All rules with drop action — key indicator of blocked traffic",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",action=\"drop\"}[$__rate_interval])", "legendFormat": "{{chain}} — {{comment}}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false }, "unit": "Bps" } },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
      "id": 11,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "Dropped Bytes (rate)",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_bytes{instance=\"$instance\",action=\"drop\"}[$__rate_interval])", "legendFormat": "{{chain}} — {{comment}}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 24, "x": 0, "y": 14 },
      "id": 12,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "All Drops by Chain",
      "description": "All rules with drop action — catch-all and explicit drops",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",action=\"drop\"}[$__rate_interval])", "legendFormat": "{{family}}/{{chain}} handle={{handle}}", "refId": "A" }]
    },
    {
      "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }, "id": 102, "title": "Input Chain", "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 },
      "id": 20,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "Input — Packets by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",chain=\"input\",table=\"filter\"}[$__rate_interval]) > 0", "legendFormat": "{{action}} {{comment}}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "Bps" } },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 },
      "id": 21,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "Input — Bytes by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_bytes{instance=\"$instance\",chain=\"input\",table=\"filter\"}[$__rate_interval]) > 0", "legendFormat": "{{action}} {{comment}}", "refId": "A" }]
    },
    {
      "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 31 }, "id": 103, "title": "Forward Chain", "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
      "id": 30,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "Forward — Packets by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",chain=\"forward\",table=\"filter\"}[$__rate_interval]) > 0", "legendFormat": "{{action}} {{comment}}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "Bps" } },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
      "id": 31,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "Forward — Bytes by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_bytes{instance=\"$instance\",chain=\"forward\",table=\"filter\"}[$__rate_interval]) > 0", "legendFormat": "{{action}} {{comment}}", "refId": "A" }]
    },
    {
      "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 40 }, "id": 104, "title": "NAT", "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 41 },
      "id": 40,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "NAT Prerouting — Packets by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",chain=\"prerouting\",table=\"nat\"}[$__rate_interval]) > 0", "legendFormat": "{{action}} {{comment}}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 41 },
      "id": 41,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "NAT Postrouting — Packets by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",chain=\"postrouting\",table=\"nat\"}[$__rate_interval]) > 0", "legendFormat": "{{action}} {{comment}}", "refId": "A" }]
    },
    {
      "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 49 }, "id": 105, "title": "IPv6 Filter", "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 50 },
      "id": 50,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "IPv6 Input — Packets by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",chain=\"input\",family=\"ip6\"}[$__rate_interval]) > 0", "legendFormat": "{{table}}/{{chain}} {{action}} {{comment}}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false, "stacking": { "mode": "normal" } }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 50 },
      "id": 51,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "lastNotNull"] }, "tooltip": { "mode": "multi" } },
      "title": "IPv6 Forward — Packets by Rule",
      "type": "timeseries",
      "targets": [{ "expr": "rate(nftables_rule_packets{instance=\"$instance\",chain=\"forward\",family=\"ip6\"}[$__rate_interval]) > 0", "legendFormat": "{{table}}/{{chain}} {{action}} {{comment}}", "refId": "A" }]
    },
    {
      "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 58 }, "id": 106, "title": "Top Rules", "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false }, "unit": "Bps" } },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 59 },
      "id": 60,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] }, "tooltip": { "mode": "multi" } },
      "title": "Top 10 Rules by Bytes",
      "type": "timeseries",
      "targets": [{ "expr": "topk(10, rate(nftables_rule_bytes{instance=\"$instance\"}[$__rate_interval]))", "legendFormat": "{{table}}/{{chain}} {{action}} {{comment}}", "refId": "A" }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, "fillOpacity": 10, "spanNulls": false }, "unit": "pps" } },
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 59 },
      "id": 61,
      "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] }, "tooltip": { "mode": "multi" } },
      "title": "Top 10 Rules by Packets",
      "type": "timeseries",
      "targets": [{ "expr": "topk(10, rate(nftables_rule_packets{instance=\"$instance\"}[$__rate_interval]))", "legendFormat": "{{table}}/{{chain}} {{action}} {{comment}}", "refId": "A" }]
    },
    {
      "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 67 }, "id": 107, "title": "Structure", "type": "row"
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": {
        "defaults": { "custom": { "align": "auto" }, "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } },
        "overrides": [{ "matcher": { "id": "byName", "options": "Chains" }, "properties": [{ "id": "custom.cellOptions", "value": { "type": "color-background" } }] }, { "matcher": { "id": "byName", "options": "Rules" }, "properties": [{ "id": "custom.cellOptions", "value": { "type": "color-background" } }] }]
      },
      "gridPos": { "h": 6, "w": 12, "x": 0, "y": 68 },
      "id": 70,
      "options": { "showHeader": true },
      "title": "Tables & Chains",
      "type": "table",
      "targets": [{ "expr": "nftables_table_chains{instance=\"$instance\"}", "refId": "A", "instant": true, "format": "table" }],
      "transformations": [{ "id": "organize", "options": { "excludeByName": { "Time": true, "__name__": true, "instance": true, "job": true }, "renameByName": { "table": "Table", "family": "Family", "Value": "Chains" } } }]
    },
    {
      "datasource": { "type": "prometheus", "uid": "${datasource}" },
      "fieldConfig": {
        "defaults": { "custom": { "align": "auto" }, "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } },
        "overrides": [{ "matcher": { "id": "byName", "options": "Rules" }, "properties": [{ "id": "custom.cellOptions", "value": { "type": "color-background" } }] }]
      },
      "gridPos": { "h": 6, "w": 12, "x": 12, "y": 68 },
      "id": 71,
      "options": { "showHeader": true },
      "title": "Chains & Rules",
      "type": "table",
      "targets": [{ "expr": "nftables_chain_rules{instance=\"$instance\"}", "refId": "A", "instant": true, "format": "table" }],
      "transformations": [{ "id": "organize", "options": { "excludeByName": { "Time": true, "__name__": true, "instance": true, "job": true }, "renameByName": { "table": "Table", "family": "Family", "chain": "Chain", "Value": "Rules" } } }]
    }
  ],
  "schemaVersion": 39,
  "tags": ["nftables", "firewall"],
  "templating": {
    "list": [
      { "current": {}, "includeAll": false, "multi": false, "name": "datasource", "query": "prometheus", "refresh": 1, "type": "datasource" },
      { "current": {}, "datasource": { "type": "prometheus", "uid": "${datasource}" }, "definition": "label_values(nftables_up, instance)", "includeAll": false, "multi": false, "name": "instance", "query": { "query": "label_values(nftables_up, instance)", "refId": "StandardVariableQuery" }, "refresh": 2, "sort": 1, "type": "query" }
    ]
  },
  "time": { "from": "now-6h", "to": "now" },
  "timepicker": {},
  "timezone": "",
  "title": "nftables Firewall",
  "uid": "nftables-firewall",
  "refresh": "30s"
}

Quick health checks

These are the first commands I use to verify that the whole stack is telling the truth:

# Verify the ruleset actually contains counters
sudo nft list ruleset | grep -c "counter"

# Check that every counted rule has a comment
sudo nft list ruleset | grep "counter" | grep -v "comment"
# This should print nothing.

# Spot-check live rules with comments
sudo nft list ruleset | grep "comment" | head -20

# Confirm the exporter is running
systemctl status prometheus-nftables-exporter

# Check the metrics endpoint
curl -s http://localhost:9630/metrics | grep nftables_up

# Find the busiest drop rules
curl -s http://localhost:9630/metrics | grep 'action="drop"' | sort -t' ' -k2 -rn | head -5

And if the exporter looks wrong, inspect the raw nftables JSON directly:

sudo nft -j list ruleset | jq .

Common failure modes

There are a few ways to make this look more broken than it is:

  • Rules without counter will never show up as time series.
  • Rules without comment will show up, but they will be hard to distinguish in dashboards.
  • Handle numbers will change after ruleset reloads or rebuilds, which is exactly why the dashboard should key off comments instead.
  • A very large ruleset means more time series, because every counted rule becomes its own metric series.
  • The exporter needs enough privilege to run nft list ruleset; the NixOS module handles that, but it is worth remembering when debugging.

The first two are the important ones. If a rule matters, give it a counter and a comment.

The transferable idea

The exporter is useful, but the real pattern here is bigger than one exporter or one firewall.

When a system already has counters but poor names, add names close to the source and export those names as labels.

That is the whole trick.

In nftables, comment is the naming layer. Once every rule carries intent alongside behavior, Prometheus and Grafana can do the rest. Your firewall stops being a wall of anonymous handles and starts being an observable system you can actually operate.