nft --check accepted the candidate ruleset reproduced for this article and left the active stateless ruleset hash unchanged. That was only the first result. After one real load, an HTTP probe to port 18192 returned 200, the same probe to port 18193 was rejected, and the intended allow and reject counters both advanced. A later replacement containing an undefined set failed while the admitted-policy hash stayed identical and a fresh allowed probe still returned 200.
Those facts answer different questions. Check mode proves that nftables can evaluate the proposed transaction without applying it. It does not prove that the policy admits the traffic you need, rejects the traffic you do not, preserves a remote administration path, or can be rolled back to a known state. This guide makes each claim observable inside an isolated network namespace before any production reload.
This reproduction uses Debian 13.6, nftables 1.1.3, one marker-owned namespace, two loopback-only HTTP listeners and an exact baseline snapshot. It never touches the host ruleset, SSH, a production interface or a systemd service. The result is a reusable admission method, not a promise that a namespace behaves exactly like your server.
Upstream’s current nft manual defines --check as checking the validity of commands without applying the changes. That is a strong precondition: nftables parses the native file, resolves objects such as named sets, and asks the kernel to validate the batch. It is still a no-apply operation, so no packet ever traverses the candidate policy during that check.
Debian’s current nft(8) manual documents the same option in the package family used here. Recording that package context matters because diagnostics, JSON details and normalized listing text can change even when the transaction contract stays the same.
Safe reloads therefore need several gates. Each has a narrow positive claim and a boundary it cannot cross.
| Gate | What it proves | What remains unproved |
|---|---|---|
nft --check -f |
The complete candidate transaction is acceptable without being applied. | Live packet behavior and continued remote access. |
| Owned allow and deny probes | The loaded ruleset makes the intended decisions for those exact flows. | Other routes, applications, address families and traffic classes. |
| Rejected real replacement | An invalid batch did not partially replace the admitted ruleset in this run. | Recovery from a valid but operationally wrong policy. |
| Exact baseline restore | The owned test scope returned to its recorded policy and behavior. | Rollback on a remote host without an independent access path. |
According to the official atomic rule replacement guide, the replacement belongs in one file loaded with nft -f. A sequence of separate shell invocations creates separate transactions and can expose intermediate policy. Atomic application prevents a partial batch; it does not make the completed policy correct.
Routing is another independent boundary. A good filter result cannot prove that replies choose the intended source or gateway. When source-specific paths matter, qualify them with a separate Linux policy-routing priority and source-path test rather than treating a passing firewall check as network-wide proof.
Run this lab only as root on a disposable Linux system. Open one root Bash shell and execute the tested-input blocks in order because they share variables and a cleanup trap. The first input rejects collisions, creates the exact namespace voxfor-nft-192, writes an ownership marker and loads a permissive baseline. The hash uses nft -s list ruleset so mutable counter values do not make the comparison drift.
set -Eeuo pipefail
LAB=/tmp/voxfor-nft-check-192
MARKER_VALUE=voxfor-nft-check-192
MARKER="$LAB/.marker"
NS=voxfor-nft-192
ALLOW_PORT=18192
DENY_PORT=18193
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
ruleset_hash() {
ip netns exec "$NS" nft -s list ruleset |
sha256sum | awk '{print $1}'
}
owned_cleanup() {
for pidfile in "$LAB"/server-*.pid; do
[[ -f "$pidfile" ]] || continue
pid=$(<"$pidfile")
if [[ "$pid" =~ ^[0-9]+$ ]] &&
kill -0 "$pid" 2>/dev/null; then
kill "$pid"
wait "$pid" 2>/dev/null || true
fi
done
if ip netns list | awk '{print $1}' | grep -qx "$NS"; then
[[ "$NS" == voxfor-nft-192 ]] ||
fail "unexpected namespace"
ip netns del "$NS"
fi
if [[ -d "$LAB" ]]; then
[[ "$LAB" == /tmp/voxfor-nft-check-192 ]] ||
fail "unexpected lab path"
[[ -f "$MARKER" ]] ||
fail "refusing cleanup without marker"
[[ "$(<"$MARKER")" == "$MARKER_VALUE" ]] ||
fail "marker mismatch"
rm -rf -- "$LAB"
fi
}
trap 'rc=$?; owned_cleanup; exit $rc' EXIT
[[ $EUID -eq 0 ]] ||
fail "root is required for a network namespace"
for tool in ip nft jq sha256sum python3 curl awk grep; do
command -v "$tool" >/dev/null || fail "missing $tool"
done
[[ ! -e "$LAB" ]] || fail "lab path already exists"
! ip netns list | awk '{print $1}' | grep -qx "$NS" ||
fail "namespace already exists"
install -d -m 0700 "$LAB" "$LAB/www"
printf '%s\n' "$MARKER_VALUE" > "$MARKER"
printf 'owned nftables probe\n' > "$LAB/www/index.html"
ip netns add "$NS"
ip netns exec "$NS" ip link set lo up
printf '%s\n' \
'flush ruleset' \
'table inet voxfor_gate {' \
' chain input {' \
' type filter hook input priority filter; policy accept;' \
' counter comment "voxfor baseline accepts owned loopback probes"' \
' }' \
' chain output {' \
' type filter hook output priority filter; policy accept;' \
' }' \
'}' > "$LAB/baseline.nft"
ip netns exec "$NS" nft -f "$LAB/baseline.nft"
{
printf 'flush ruleset\n'
ip netns exec "$NS" nft -s list ruleset
} > "$LAB/baseline.snapshot.nft"
BASELINE_HASH=$(ruleset_hash)
Marker validation lets the cleanup function delete only one literal namespace and one literal directory. The trap runs on both success and failure. That boundary matters more than convenience: never replace the exact path with a parent directory, wildcard or production namespace.
Start two owned listeners and prove they both work under the baseline. Port 18192 will become the positive case; port 18193 will become the negative case. Without the baseline probes, a later connection failure could mean “server never started” rather than “firewall rejected it.”
ip netns exec "$NS" python3 -m http.server "$ALLOW_PORT" \
--bind 127.0.0.1 --directory "$LAB/www" \
>"$LAB/server-allow.log" 2>&1 &
printf '%s\n' "$!" > "$LAB/server-allow.pid"
ip netns exec "$NS" python3 -m http.server "$DENY_PORT" \
--bind 127.0.0.1 --directory "$LAB/www" \
>"$LAB/server-deny.log" 2>&1 &
printf '%s\n' "$!" > "$LAB/server-deny.pid"
for _ in {1..30}; do
BASE_ALLOW=$(
ip netns exec "$NS" curl --noproxy '*' -s \
-o /dev/null -w '%{http_code}' \
"http://127.0.0.1:$ALLOW_PORT/" 2>/dev/null || true
)
BASE_CONTROL=$(
ip netns exec "$NS" curl --noproxy '*' -s \
-o /dev/null -w '%{http_code}' \
"http://127.0.0.1:$DENY_PORT/" 2>/dev/null || true
)
[[ "$BASE_ALLOW" == 200 && "$BASE_CONTROL" == 200 ]] &&
break
sleep 0.1
done
[[ "$BASE_ALLOW" == 200 && "$BASE_CONTROL" == 200 ]] ||
fail "owned listeners did not become ready"
Production remote reloads need a second management path that is already tested, not merely documented. Use OpenSSH authorized_keys permissions workflow to qualify key-file ownership before relying on that path. If nobody on the team owns the reload, independent access and recovery, escalate that responsibility to managed server operations before changing the firewall; the link is an ownership option, not evidence that a service has approved your ruleset.
candidate.nft begins with flush ruleset and then declares its replacement table, set and chains. Because the entire text reaches nft -f as one native batch, the flush and replacement share one transaction. The official nftables scripting guide is explicit that native files preserve transactional behavior that a shell loop of individual nft add rule calls does not.
Inside it, the policy is deliberately narrow. Loopback traffic to the named allowed-port set is accepted, established flows are retained, other loopback input is explicitly rejected, and output is accepted. These rules belong only to the isolated namespace.
printf '%s\n' \
'flush ruleset' \
'table inet voxfor_gate {' \
' set allowed_ports {' \
' type inet_service;' \
' elements = { 18192 }' \
' }' \
' chain input {' \
' type filter hook input priority filter; policy drop;' \
' iifname "lo" ct state established,related counter accept comment "keep established loopback flows"' \
' iifname "lo" tcp dport @allowed_ports counter accept comment "admit owned HTTP probe"' \
' iifname "lo" counter reject with icmpx type port-unreachable comment "reject other owned loopback input"' \
' }' \
' chain output {' \
' type filter hook output priority filter; policy accept;' \
' }' \
'}' > "$LAB/candidate.nft"
Check the complete file and compare the active representation on both sides. The same hash means check mode did not change the stateless listed ruleset in this namespace. It is not a cryptographic statement about every hidden kernel object, and equality with a hash from another machine is irrelevant.
BEFORE_CHECK_HASH=$(ruleset_hash)
ip netns exec "$NS" nft --check -f "$LAB/candidate.nft"
AFTER_CHECK_HASH=$(ruleset_hash)
[[ "$BEFORE_CHECK_HASH" == "$AFTER_CHECK_HASH" ]] ||
fail "check mode mutated the ruleset"
One bad file breaks the grammar with an unknown statement. The other remains structurally plausible but refers to a set that was never declared. Requiring both to fail distinguishes parser rejection from object-resolution rejection; duplicating the same typo twice would not add evidence.
printf '%s\n' \
'flush ruleset' \
'table inet voxfor_gate {' \
' chain input {' \
' type filter hook input priority filter; policy drop;' \
' tcp dport 18192 accept definitely_not_a_statement' \
' }' \
'}' > "$LAB/syntax-bad.nft"
printf '%s\n' \
'flush ruleset' \
'table inet voxfor_gate {' \
' chain input {' \
' type filter hook input priority filter; policy drop;' \
' ip saddr @never_defined counter accept' \
' }' \
'}' > "$LAB/missing-set.nft"
set +e
SYNTAX_OUTPUT=$(
ip netns exec "$NS" nft --check -f "$LAB/syntax-bad.nft" 2>&1
)
SYNTAX_RC=$?
MISSING_OUTPUT=$(
ip netns exec "$NS" nft --check -f "$LAB/missing-set.nft" 2>&1
)
MISSING_RC=$?
set -e
[[ $SYNTAX_RC -ne 0 && $MISSING_RC -ne 0 ]] ||
fail "negative check was admitted"
In the reproduced run both exit codes were 1. The parser control reported an unexpected newline after the invented statement; the object control reported “No such file or directory” at @never_defined. Exact wording can vary by nftables version, so automation should require nonzero status and retain the diagnostic rather than match one complete English sentence.
Other ruleset owners may coexist on the host. Firewalld, UFW, Fail2ban, container networking and custom automation can add or replace policy outside this file. Identity conflicts reproduced in Fail2ban reverse-proxy enforcement lab show why enforcement authority must be explicit. Do not call one file “the whole firewall” until you have inventoried every writer.
ArchWiki’s nftables operations page likewise warns about coexistence with other firewall management tools. Inventorying writers is therefore an admission step, not a distribution-specific footnote.
Loading the real candidate is the first moment this guide asks nftables to change policy, and it changes only the owned namespace. Immediately exercise one positive and one negative flow, then inspect the table as JSON. The set and rule counts catch missing objects; the comments make the counters attributable to the intended rules.
ip netns exec "$NS" nft -f "$LAB/candidate.nft"
CANDIDATE_HASH=$(ruleset_hash)
ALLOW_HTTP=$(
ip netns exec "$NS" curl --noproxy '*' -sS \
-o /dev/null -w '%{http_code}' \
"http://127.0.0.1:$ALLOW_PORT/"
)
set +e
DENY_OUTPUT=$(
ip netns exec "$NS" curl --noproxy '*' -sS \
-o /dev/null -w 'http=%{http_code}' \
--connect-timeout 2 \
"http://127.0.0.1:$DENY_PORT/" 2>&1
)
DENY_RC=$?
set -e
[[ "$ALLOW_HTTP" == 200 && $DENY_RC -ne 0 ]] ||
fail "packet behavior did not match candidate policy"
COUNTER_JSON=$(
ip netns exec "$NS" nft -j list table inet voxfor_gate |
jq -c '{
sets: ([.nftables[] | select(.set)] | length),
rules: ([.nftables[] | select(.rule)] | length),
counters: [
.nftables[] | select(.rule) |
{
comment: .rule.comment,
packets: (
.rule.expr[]? | select(.counter?) | .counter.packets
)
}
]
}'
)
[[ $(printf '%s' "$COUNTER_JSON" | jq -r '.sets') == 1 ]]
[[ $(printf '%s' "$COUNTER_JSON" | jq -r '.rules') == 3 ]]
Even a valid load can encode a wrong business rule. That is why the allowed and denied endpoints must represent real production requirements when you adapt the method. Choose probes that are owned, secret-free and reversible; do not scan arbitrary systems or assume that “TCP connect succeeded” proves an application transaction.
Now attempt a real replacement with the undefined-set file. This is stronger than repeating --check: the command asks the kernel to apply a batch beginning with flush ruleset. The required result is rejection, the same admitted-policy hash, and a new HTTP 200 from the allowed listener.
BEFORE_FAILED_LOAD=$(ruleset_hash)
set +e
FAILED_OUTPUT=$(
ip netns exec "$NS" nft -f "$LAB/missing-set.nft" 2>&1
)
FAILED_RC=$?
set -e
AFTER_FAILED_LOAD=$(ruleset_hash)
[[ $FAILED_RC -ne 0 ]] ||
fail "invalid transaction unexpectedly loaded"
[[ "$BEFORE_FAILED_LOAD" == "$AFTER_FAILED_LOAD" ]] ||
fail "failed transaction changed the ruleset"
STILL_ALLOWED=$(
ip netns exec "$NS" curl --noproxy '*' -sS \
-o /dev/null -w '%{http_code}' \
"http://127.0.0.1:$ALLOW_PORT/"
)
[[ "$STILL_ALLOWED" == 200 ]] ||
fail "admitted policy did not survive failed transaction"
This is the decisive atomicity control: the leading flush did not erase the admitted policy before the undefined reference caused the transaction to fail. It does not authorize a blind production load. A syntactically and semantically valid file can still drop the operator’s SSH flow, omit IPv6, accept an unintended source or conflict with another policy manager.
Runtime loss can also occur below or beside the rule file. An exhausted Linux conntrack table changes connection behavior without making the nft file invalid. A saturated softnet/NAPI backlog can drop packets before an application responds. Keep those diagnoses separate so a passing reload gate is not mistaken for end-to-end service health.
Retain the representative receipt from the isolated run with the change record:
environment=nftables_v1.1.3_(Commodore_Bullmoose_#4) kernel=6.12.101+deb13-amd64 namespace=voxfor-nft-192
baseline_hash=328efca1d36978ab2627160c769adf17941284b9abda036179b2c830db2dc351
baseline_probe allow=200 control=200
check_candidate=accepted before=328efca1d36978ab2627160c769adf17941284b9abda036179b2c830db2dc351 after=328efca1d36978ab2627160c769adf17941284b9abda036179b2c830db2dc351 unchanged=yes
syntax_control=blocked rc=1 diagnostic=syntax error, unexpected newline
missing_set_control=blocked rc=1 diagnostic=No such file or directory
candidate_hash=11363074cc45c00617453ca726872c2aa73455aa64e445e59ba2778b1de4f7ce allowed_port=18192 http=200 denied_port=18193 denied_rc=7 http=000
{"sets":1,"rules":3,"counters":[{"comment":"keep established loopback flows","packets":12},{"comment":"admit owned HTTP probe","packets":1},{"comment":"reject other owned loopback input","packets":1}]}
failed_load=blocked rc=1 hash_before=11363074cc45c00617453ca726872c2aa73455aa64e445e59ba2778b1de4f7ce hash_after=11363074cc45c00617453ca726872c2aa73455aa64e445e59ba2778b1de4f7ce unchanged=yes probe_after=200 diagnostic=No such file or directory
rollback_hash=328efca1d36978ab2627160c769adf17941284b9abda036179b2c830db2dc351 matches_baseline=yes allow=200 control=200
cleanup_scope=namespace:voxfor-nft-192 path:/tmp/voxfor-nft-check-192 marker:voxfor-nft-check-192
Hashes can differ on a separate run because the ruleset text or tool version can differ. Judge equality inside one receipt: baseline around check mode, admitted policy around the failed transaction, and restored state against that run’s baseline.
nft --check change the active ruleset?No. The upstream nft manual defines check mode as validating commands without applying changes. In this reproduction, the stateless ruleset hash was identical before and after nft --check -f candidate.nft. Keep the before/after comparison because a zero exit alone records acceptance, not your non-mutation evidence.
Check validity proves that nftables accepts the transaction, not that the completed policy allows the operator’s source, address family, interface and destination port. Test an owned management flow from an independent path and prepare a timed or console-backed rollback before a remote reload.
Yes, when the flush and replacement are commands in the same native nft file, they are evaluated as one transaction. The failed-load control in this run began with flush ruleset but left the admitted policy unchanged after an undefined-set error. Separate shell invocations are separate transactions and do not have that boundary.
They exercise different validation layers. The invented statement must lose at parsing, while @never_defined must lose when nftables resolves the candidate’s objects. One repeated typo would not show that a structurally valid-looking file with a missing dependency is rejected.
Counters can prove that packets traversed a specific observed rule during the test, especially when stable comments identify it. They do not prove that every production flow takes the same path, and accumulated counters can include unrelated traffic. Record a before/after delta for owned probes in a busy environment.
No. The namespace proves transaction and packet behavior for its own interfaces, listeners, routes and kernel. Production can add physical devices, IPv6, policy routing, conntrack pressure, container bridges, other ruleset writers and remote-access failure modes. Rebuild the probe matrix around those real dependencies.
Only when the test is designed not to terminate the session that performs recovery. Prefer an independent console or second connection, test the exact management tuple, and keep an automatic rollback outside the candidate ruleset’s failure domain. A single existing SSH session is not a recovery plan.
Stop before promotion, preserve the candidate, diagnostics and hashes, and restore only the previously captured ruleset through the independent access path. Verify both management and application flows after restoration. If ownership or evidence is ambiguous, do not improvise a broader flush.
Restoration begins by reloading the captured baseline snapshot and requiring both its stateless hash and its original two HTTP responses. Hash parity alone would miss a listener problem; two green probes alone would miss unrecorded policy text.
ip netns exec "$NS" nft -f "$LAB/baseline.snapshot.nft"
RESTORED_HASH=$(ruleset_hash)
[[ "$RESTORED_HASH" == "$BASELINE_HASH" ]] ||
fail "baseline hash did not return"
RESTORED_ALLOW=$(
ip netns exec "$NS" curl --noproxy '*' -sS \
-o /dev/null -w '%{http_code}' \
"http://127.0.0.1:$ALLOW_PORT/"
)
RESTORED_CONTROL=$(
ip netns exec "$NS" curl --noproxy '*' -sS \
-o /dev/null -w '%{http_code}' \
"http://127.0.0.1:$DENY_PORT/"
)
[[ "$RESTORED_ALLOW" == 200 &&
"$RESTORED_CONTROL" == 200 ]] ||
fail "baseline behavior did not return"
Cleanup then invokes only the already guarded function, disables the exit trap after success, and proves both owned targets are absent. Listener PID files, namespace identity and marker validation prevent the cleanup from widening silently.
owned_cleanup
trap - EXIT
[[ ! -e "$LAB" ]] ||
fail "owned lab path remained after cleanup"
! ip netns list | awk '{print $1}' | grep -qx "$NS" ||
fail "owned namespace remained after cleanup"
Admit a candidate to the next change gate only when the exact nftables and kernel versions are recorded; the test scope is owned and collision-free; both baseline probes work; check mode accepts the complete native file without changing the active stateless hash; parser-invalid and undefined-object controls both fail; the real candidate produces the intended allow and deny outcomes with attributable counters; a real invalid replacement leaves the admitted hash and allowed probe intact; the baseline hash and both baseline probes return; and marker-scoped cleanup leaves neither namespace nor lab path. Remote SSH continuity, routing, other ruleset writers, IPv6, application transactions and production capacity remain separate required decisions.
If any clause fails, stop before production promotion. Preserve the candidate, diagnostic, hashes and probe results; use the independent access path to reload only the previously captured production ruleset; recheck management and application flows; and investigate the failed gate. In this lab, rollback is limited to baseline.snapshot.nft inside voxfor-nft-192, followed by deletion of only that namespace and the marker-validated /tmp/voxfor-nft-check-192 path. Never broaden the cleanup target or run an unqualified host-level flush ruleset.
Admission is complete when every requested transition has evidence: no change during check mode, intended behavior after the valid load, no partial change after the invalid load, and exact restoration afterward. That chain is the firewall reload test; nft --check is its first gate.