VPS nftables Firewall With Docker

VPS nftables Firewall With Docker

This note records a small nftables firewall setup for a VPS that also runs Docker.

The goal is not to write a clever universal ruleset. The goal is simpler: keep the host input path small, expose only the ports that should be public, and avoid fighting Docker's own NAT rules.

The example uses documentation IP addresses:

  • 203.0.113.10 as the admin's trusted IP
  • 198.51.100.20 as the server IP
  • 22222 as the SSH port

Replace them with your own values.

Basic Rules

Use the inet family so the same table can handle IPv4 and IPv6:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/usr/sbin/nft -f

# Manage only this article's dedicated table. add is idempotent;
# delete and recreate it in the same nft transaction.
add table inet vps_host
delete table inet vps_host

table inet vps_host {
set trusted_ipv4 {
type ipv4_addr
flags interval
elements = {
203.0.113.10,
}
}

chain input {
type filter hook input priority filter; policy drop;

iif "lo" accept
ct state established,related accept
ct state invalid drop

ip protocol icmp accept
ip6 nexthdr icmpv6 accept

# SSH: only allow the trusted admin IP.
ip saddr @trusted_ipv4 tcp dport 22222 accept

# Public web entry.
tcp dport { 80, 443 } accept
}

}

Docker Notes

Docker manages its own forwarding and NAT rules. A global ruleset flush deletes those rules regardless of the replacement forward policy; forward accept cannot reconstruct NAT. This file only recreates inet vps_host and leaves other tables untouched. Do not share that table name with another firewall manager. Existing base-chain drops can still block traffic: acceptance in one table does not override a later drop.

Docker-published bridge ports usually traverse forward, not host input, so this host-only policy does not restrict them. Bind private published ports to localhost or apply forwarding restrictions through the interface documented for your installed Docker firewall backend. This example does not establish backend-independent container filtering.

For a small VPS, I usually keep the boundary simple:

  • protect the host through the input chain
  • let Docker manage container NAT
  • expose public services through Caddy on 80/443
  • bind internal app ports to 127.0.0.1 when possible

Example Docker port mapping:

1
2
ports:
- "127.0.0.1:8080:8080"

Then let Caddy publish it over HTTPS.

Apply Safely

Save this dedicated-table example to a separate root-owned file such as /etc/nftables-vps-host.nft; do not overwrite or enable a distribution service whose startup/stop actions flush the whole ruleset. The following is only a check, not an application command. Verify your actual admin source address and SSH listening port first, including IPv6 needs (the example allows SSH only from the specified IPv4 address).

Before any live load, keep provider-console access and a second SSH session, save the current ruleset for diagnosis, and arrange a timed rollback that removes or restores only inet vps_host. Check the exact rollback facility on your distribution. Restoring an old full ruleset can overwrite Docker rules created since the snapshot. Then validate before loading:

1
sudo nft -c -f /etc/nftables-vps-host.nft