Build and Check an nftables Ruleset in JSON

One typo in an nft rule can lock you out over SSH, so this checks a JSON ruleset before it goes live. It builds a small one from scratch and validates it against the kernel step by step. The example creates a table, an input chain and an SSH accept rule. Allow about 15 minutes, plus time to decide whether the example policy belongs on your host.

This guide uses nft from nftables 1.0.9, package version 1.0.9-1ubuntu0.1, on the reference machine. The installed libnftables-json(5) page describes the JSON schema used by the library and by the nft frontend. The schema is an alternative frontend to ordinary nft syntax, not a separate firewall engine.

1. Check the tools and current access

JSON parsing and file inspection are ordinary-user operations. Reading or changing the kernel ruleset normally needs root, so use sudo only for those nft commands when your account is not already privileged.

$ nft --version
nftables v1.0.9 (Old Doc Yak #3)
$ command -v jq
/usr/bin/jq

jq is not part of nftables; it is a convenient independent check that the file is valid JSON. If it is unavailable, use another JSON parser or run nft in check mode later.

Warning: do not begin with flush ruleset. A flush removes the current rules, which can cut off remote access and discard policy you never saved. This guide creates a uniquely named table instead.

2. Write the JSON ruleset

Save the following as example-filter.json. Replace example_filter only if that name is already used. The outer object has one property, nftables, whose array holds commands in execution order. Each command has one key such as add; its value is the object being added.

{
  "nftables": [
    { "add": { "table": { "family": "inet", "name": "example_filter" } } },
    { "add": { "chain": {
      "family": "inet",
      "table": "example_filter",
      "name": "input",
      "type": "filter",
      "hook": "input",
      "prio": 0,
      "policy": "accept"
    } } },
    { "add": { "rule": {
      "family": "inet",
      "table": "example_filter",
      "chain": "input",
      "expr": [
        { "match": {
          "op": "==",
          "left": { "payload": { "protocol": "tcp", "field": "dport" } },
          "right": 22
        } },
        { "accept": null }
      ]
    } } }
  ]
}

This policy accepts TCP traffic on port 22, but the chain policy is also accept. It is a learning example, not a complete restrictive firewall. Do not copy it as a claim that every other packet gets blocked.

3. Validate the file without touching nftables

First ask jq to parse the document:

$ jq empty example-filter.json
$ printf '%s\n' "$?"
0

No output from jq empty means it accepted the JSON. A non-zero status means the file is malformed, usually a missing comma, brace or quote. Fix that before investigating nftables at all.

Next ask nft to check the file. -c requests check mode, -j selects JSON output, and -f reads commands from a file:

$ sudo nft -c -j -f example-filter.json

A successful check returns status 0 and should not install the table or rule. On a restricted machine this command can fail before parsing because nft cannot initialise its netlink cache; that is an access or environment failure, not proof the JSON is wrong. Keep the actual error text when diagnosing it.

Checkpoint: inspect both the JSON parser status and nft's status before applying anything. Do not treat an empty output stream as success; the exit status is the result that matters.

4. Inspect the live ruleset before applying

Take a JSON snapshot if you have permission to read the ruleset:

$ sudo nft -j list ruleset > ruleset-before.json
$ jq empty ruleset-before.json

The output shares the same outer nftables array. When produced through libnftables, its first object may be metainfo, holding the library version, release name and JSON schema version. Do not copy that metadata blindly into input: the parser rejects a supplied schema version higher than the one it understands, and a lower value is reserved for possible compatibility handling.

If the snapshot is empty or the command fails, stop and resolve the access problem. A missing snapshot is not a reason to flush or overwrite the live configuration.

5. Apply the commands deliberately

Applying the file changes kernel firewall state and may affect network traffic. Have console access or a tested recovery route ready before continuing, especially over SSH. Once the check has passed, apply the file with elevated privileges:

$ sudo nft -j -f example-filter.json

Commands in the array run in order. If the table already exists, add reports an error; use create when an existing object should be an error by design. For a rule, insert places it first by default, while add appends it. replace needs a rule handle, and delete normally needs enough family, table and name or handle information to identify the object.

Verify the example table now exists:

$ sudo nft -j list table inet example_filter | jq .
{
  "nftables": [
    {
      "table": {
        "family": "inet",
        "name": "example_filter"
      }
    }
  ]
}

Exact formatting and extra fields can vary. The useful check is that the command succeeds and the returned object names family inet and table example_filter. List the chain or ruleset too if you need to confirm the rule itself.

6. Remove only the example table if you need to undo it

Do not reach for a broad flush to undo a test. Delete the named table instead, which also removes the chain and rule inside it:

$ sudo nft delete table inet example_filter
$ sudo nft -j list table inet example_filter
table inet example_filter does not exist

The exact diagnostic text can differ by nftables release; the result that matters is a non-zero status because the table is gone. If you need to restore an earlier complete ruleset, review ruleset-before.json first and use it only after confirming it is a trusted snapshot for this host. Restoration can itself change connectivity, so keep an out-of-band recovery path ready.

Common traps

Done means