When running a Linux server directly on the Internet, SSH and other management ports are constantly scanned. One simple way to reduce exposure is to allow access only from trusted IP addresses.
This guide shows how to:
- Protect a custom SSH port with an IP allowlist
- Restrict TCP/UDP port 443 to a trusted source
- Restrict ICMP ping requests without completely breaking ICMP
- Resolve a trusted hostname dynamically with
dig - Automatically refresh the firewall when the hostname IP changes
- Run the update script using
crontab - Avoid exposing personal or production IP addresses in a public script
Important: All IP addresses in this article are documentation examples. They are not real server addresses.
For example:
203.0.113.10
198.51.100.20
192.0.2.30
These address ranges are intended for documentation and examples.
1. The Basic Idea
Imagine that I have a trusted hostname:
trusted.example.com
The IP behind this hostname may change.
Instead of manually editing the firewall every time the IP changes, the server can periodically resolve the hostname:
dig +short A trusted.example.com
The returned IP is then inserted into an nftables ruleset.
The workflow looks like this:
trusted.example.com
|
v
DNS
|
v
203.0.113.x
|
v
Shell Script
|
v
nftables
|
v
SSH / HTTPS / ICMP filtering
A cron job can run the script every few minutes so that the firewall follows DNS changes automatically.
2. Understanding the Original Script
A simplified version of the original approach looks like this:
#!/bin/bash
export PATH=$PATH:/usr/sbin:/usr/bin:/sbin:/bin
declare -A HOSTS=(
[trusted]="trusted.example.com"
)
for key in "${!HOSTS[@]}"; do
ip=$(dig +short "${HOSTS[$key]}" \
| grep -Eo '^[0-9.]+$' \
| head -n1)
if [[ -z "$ip" ]]; then
echo "Unable to resolve ${HOSTS[$key]}" >&2
exit 1
fi
echo "${HOSTS[$key]} -> $ip"
done
The PATH line is especially useful when the script runs from cron because cron usually has a smaller environment than an interactive shell.
The associative array allows multiple hostnames to be added later:
declare -A HOSTS=(
[office]="office.example.com"
[home]="home.example.com"
)
The dig command resolves the hostname:
dig +short A trusted.example.com
The script then extracts an IPv4 address.
3. One Important Problem: Do Not Flush the Firewall Before DNS Works
A dangerous pattern is:
nft flush ruleset
ip=$(dig ...)
If DNS fails after the firewall has already been flushed, the server temporarily has no nftables protection.
A much safer order is:
1. Resolve DNS
2. Validate the result
3. Build the new firewall
4. Replace the old firewall
So DNS should always be checked first.
4. Protecting the SSH Port
Suppose SSH is running on TCP port:
122
This is only an example. Changing the SSH port reduces automated noise, but it should not be considered a replacement for authentication security.
The firewall can allow SSH only from trusted addresses:
ip saddr @trusted_v4 tcp dport 122 accept
tcp dport 122 drop
The order matters.
First:
ip saddr @trusted_v4 tcp dport 122 accept
allows trusted addresses.
Then:
tcp dport 122 drop
blocks everybody else.
The result is effectively:
Trusted IP -> TCP 122 -> ACCEPT
Other IP -> TCP 122 -> DROP
SSH normally does not need UDP
Standard OpenSSH uses TCP.
Therefore this is normally unnecessary:
udp dport 122 accept
Unless you are actually running another UDP-based service on port 122, there is no reason to expose it.
5. Protecting Port 443
The same approach can be used for HTTPS:
ip saddr @trusted_v4 tcp dport 443 accept
tcp dport 443 drop
If HTTP/3 or QUIC is being used, UDP 443 may also be necessary:
ip saddr @trusted_v4 udp dport 443 accept
udp dport 443 drop
If HTTP/3 is not used, UDP 443 can simply remain blocked.
Be careful with this rule on a public website.
If you do:
tcp dport 443 drop
after allowing only a private trusted IP, ordinary visitors will no longer be able to access HTTPS.
This configuration therefore makes sense for private services, management interfaces, VPN entry points, reverse-proxy links, or other restricted services.
6. ICMP Should Not Be Completely Blocked
A common firewall rule is:
ip protocol icmp drop
I generally avoid this.
ICMP is not only used by ping.
It also provides important network error messages and troubleshooting information.
Instead, I prefer blocking or restricting only ICMP echo requests while allowing important ICMP error messages.
For example:
ip protocol icmp icmp type {
destination-unreachable,
time-exceeded,
parameter-problem
} accept
Then allow ping only from trusted addresses:
ip saddr @trusted_v4 icmp type echo-request \
limit rate 5/second burst 10 packets accept
Finally block other IPv4 ping requests:
icmp type echo-request drop
This produces behavior similar to:
Trusted IP -> Ping server -> Allowed
Unknown IP -> Ping server -> Dropped
while not blindly dropping every type of ICMP packet.
7. Do Not Blindly Block ICMPv6
IPv6 is different.
ICMPv6 is an important part of IPv6 operation and is used for functions such as neighbor discovery.
Therefore this kind of rule is a bad idea:
Drop every ICMPv6 packet
If the server uses IPv6, create proper IPv6 and ICMPv6 policies separately.
The example in this article focuses primarily on IPv4 source allowlisting.
8. A Safer Reference Script
Here is the version I would use as a reusable template.
Create:
/usr/local/sbin/dynamic-nftables.sh
with the following contents:
#!/usr/bin/env bash
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# ------------------------------------------------------------
# Configuration
# ------------------------------------------------------------
TRUSTED_HOST="trusted.example.com"
# Example custom SSH port
SSH_PORT="122"
# HTTPS port
HTTPS_PORT="443"
# Optional emergency/static administrator IP.
#
# 203.0.113.10 is a documentation-only example address.
# Replace it with your own trusted administrator IP.
STATIC_ADMIN_IP="203.0.113.10"
# ------------------------------------------------------------
# Safety checks
# ------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root." >&2
exit 1
fi
command -v nft >/dev/null 2>&1 || {
echo "nft command not found." >&2
exit 1
}
command -v dig >/dev/null 2>&1 || {
echo "dig command not found." >&2
exit 1
}
# ------------------------------------------------------------
# Resolve the trusted hostname BEFORE touching the firewall
# ------------------------------------------------------------
TRUSTED_IP=$(
dig +short A "$TRUSTED_HOST" |
grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' |
head -n1
)
if [[ -z "$TRUSTED_IP" ]]; then
echo "ERROR: Unable to resolve $TRUSTED_HOST" >&2
exit 1
fi
echo "$TRUSTED_HOST -> $TRUSTED_IP"
# ------------------------------------------------------------
# Apply nftables rules
# ------------------------------------------------------------
nft -f - <<EOF
flush ruleset
table inet filter {
set trusted_v4 {
type ipv4_addr
elements = {
$TRUSTED_IP,
$STATIC_ADMIN_IP
}
}
chain input {
type filter hook input priority 0;
policy accept;
# Keep existing connections working
ct state established,related accept
# Always allow localhost
iifname "lo" accept
# ----------------------------------------------------
# HTTPS
# ----------------------------------------------------
ip saddr @trusted_v4 tcp dport $HTTPS_PORT accept
ip saddr @trusted_v4 udp dport $HTTPS_PORT accept
tcp dport $HTTPS_PORT drop
udp dport $HTTPS_PORT drop
# ----------------------------------------------------
# SSH
# ----------------------------------------------------
ip saddr @trusted_v4 tcp dport $SSH_PORT accept
tcp dport $SSH_PORT drop
# ----------------------------------------------------
# ICMP
# ----------------------------------------------------
# Keep useful IPv4 ICMP error messages
ip protocol icmp icmp type {
destination-unreachable,
time-exceeded,
parameter-problem
} accept
# Allow ping only from trusted addresses
ip saddr @trusted_v4 icmp type echo-request \
limit rate 5/second burst 10 packets accept
# Block other ping requests
icmp type echo-request drop
}
chain forward {
type filter hook forward priority 0;
policy accept;
}
chain output {
type filter hook output priority 0;
policy accept;
}
}
EOF
echo
echo "Current nftables rules:"
nft list ruleset
echo
echo "Firewall successfully updated."
9. Why I Use an nftables Set
Instead of repeating rules such as:
ip saddr 203.0.113.10 ...
ip saddr 198.51.100.20 ...
I create an nftables set:
set trusted_v4 {
type ipv4_addr
elements = {
203.0.113.10,
198.51.100.20
}
}
The rules can then simply reference:
ip saddr @trusted_v4
For example:
ip saddr @trusted_v4 tcp dport 122 accept
This is easier to read and much easier to maintain.
10. Why ct state established,related Is Useful
This rule is important:
ct state established,related accept
It allows packets belonging to an existing connection.
For example, imagine that the trusted hostname originally resolves to:
203.0.113.20
You establish an SSH connection.
Later the hostname changes to:
203.0.113.21
After the firewall refreshes, new connections from the old address can be rejected, but an already established connection can continue normally.
This is especially helpful when updating a firewall remotely.
11. Why the Input Policy Is Still accept
The example uses:
policy accept;
instead of:
policy drop;
This is intentional.
This tutorial focuses on protecting specific sensitive services:
SSH
HTTPS
ICMP echo requests
It is not intended to be a complete host firewall.
A server with:
policy drop;
needs explicit rules for every service that must remain reachable.
That is a stronger security model, but blindly changing an existing server to a default-drop policy can easily break services or lock the administrator out.
Once all required services have been identified, moving to:
policy drop;
can be a good next step.
12. Why Some output Rules Do Nothing
Consider this configuration:
chain output {
type filter hook output priority 0;
policy accept;
}
ip daddr 203.0.113.10 accept
ip daddr 198.51.100.10 accept
Because the default output policy is already:
policy accept;
those additional accept rules do not restrict anything.
Traffic to every other destination is also accepted.
To implement true outbound filtering, the policy would have to become something such as:
policy drop;
followed by explicit rules for DNS, package repositories, NTP, HTTPS, monitoring systems, and other required services.
That is a separate topic and should be configured carefully because overly aggressive outbound filtering can easily break a server.
13. Install the Required Packages
On Debian or Ubuntu:
sudo apt update
sudo apt install nftables dnsutils
dnsutils provides the dig command.
On AlmaLinux, Rocky Linux, RHEL, or similar systems:
sudo dnf install nftables bind-utils
14. Save the Script
Create the file:
sudo nano /usr/local/sbin/dynamic-nftables.sh
Paste the script into it.
Then make it executable:
sudo chmod 700 /usr/local/sbin/dynamic-nftables.sh
The permission:
700
means that only root can read, modify, or execute the script.
You can verify it with:
ls -l /usr/local/sbin/dynamic-nftables.sh
15. Check the Shell Script Before Running It
Before modifying a firewall, check the Bash syntax:
sudo bash -n /usr/local/sbin/dynamic-nftables.sh
If there is no output, the shell syntax is valid.
Then execute it manually:
sudo /usr/local/sbin/dynamic-nftables.sh
Finally inspect the rules:
sudo nft list ruleset
16. Very Important SSH Safety Procedure
When modifying firewall rules over SSH, I recommend keeping the existing SSH connection open.
Do not immediately close it.
After applying the firewall:
- Keep the current SSH session connected.
- Open a second terminal.
- Try connecting to SSH again.
- Verify that your trusted address works.
- Verify the firewall rules.
- Only then close the original SSH session.
It is also a good idea to have access to the VPS provider’s web console, serial console, KVM, or recovery console before experimenting with remote firewall rules.
A small firewall mistake can otherwise turn into a very inconvenient afternoon.
17. Test SSH
From an authorized network:
ssh -p 122 user@your-server
The connection should succeed.
From a source IP that is not in the trusted set, the connection should fail.
You can verify the SSH rule with:
sudo nft list chain inet filter input
18. Test ICMP
From the trusted address:
ping your-server
It should work.
From another Internet connection:
ping your-server
The server should not respond to the ICMP echo requests.
This does not mean the server is invisible.
A firewall should never be treated as a method for making a server completely undetectable.
19. Automatically Update the Firewall with Cron
The main reason for resolving a hostname is that its IP may change.
Instead of manually running:
sudo /usr/local/sbin/dynamic-nftables.sh
every time the DNS record changes, we can use cron.
Because firewall modification requires root privileges, edit the root crontab:
sudo crontab -e
Add:
*/5 * * * * /usr/bin/flock -n /run/dynamic-nftables.lock /usr/local/sbin/dynamic-nftables.sh >> /var/log/dynamic-nftables.log 2>&1
This runs the firewall update every five minutes.
The schedule:
*/5 * * * *
means:
Every 5 minutes
Every hour
Every day
Every month
Every day of the week
20. Why Use flock?
I use:
flock -n /run/dynamic-nftables.lock
to prevent two copies of the script from running at the same time.
Normally the script should finish very quickly.
However, using a lock is still a simple way to avoid overlapping executions caused by DNS delays, system load, or accidental manual execution.
21. Save Cron Output to a Log
This part:
>> /var/log/dynamic-nftables.log 2>&1
stores both normal output and errors in:
/var/log/dynamic-nftables.log
You can inspect it with:
sudo tail -f /var/log/dynamic-nftables.log
A successful update might look like:
trusted.example.com -> 203.0.113.25
Current nftables rules:
...
Firewall successfully updated.
If DNS fails, you may instead see:
ERROR: Unable to resolve trusted.example.com
The important part is that DNS was checked before the existing firewall was replaced.
22. Apply the Firewall After Reboot
Cron can also run the script when the machine boots.
Edit:
sudo crontab -e
and add:
@reboot sleep 15 && /usr/bin/flock -n /run/dynamic-nftables.lock /usr/local/sbin/dynamic-nftables.sh >> /var/log/dynamic-nftables.log 2>&1
I use a short delay because the network and DNS resolver may not be completely ready immediately after the operating system starts.
The complete root crontab might therefore contain:
@reboot sleep 15 && /usr/bin/flock -n /run/dynamic-nftables.lock /usr/local/sbin/dynamic-nftables.sh >> /var/log/dynamic-nftables.log 2>&1
*/5 * * * * /usr/bin/flock -n /run/dynamic-nftables.lock /usr/local/sbin/dynamic-nftables.sh >> /var/log/dynamic-nftables.log 2>&1
If DNS is unavailable during the @reboot execution, the scheduled five-minute job can try again later.
23. Verify That Cron Is Installed
Depending on the distribution, the service may be called cron or crond.
On Debian/Ubuntu:
systemctl status cron
On RHEL-compatible distributions:
systemctl status crond
To enable it if necessary:
Debian/Ubuntu:
sudo systemctl enable --now cron
RHEL-compatible systems:
sudo systemctl enable --now crond
24. Why Not Put a Domain Name Directly in the Firewall?
It may be tempting to write something similar to:
Allow trusted.example.com
and expect the firewall to automatically follow DNS changes forever.
That is not how this design works.
The hostname is resolved when the rules are created. If the DNS record later changes, the existing firewall rule does not magically update itself.
That is why the process is:
DNS hostname
↓
dig
↓
Current IP
↓
nftables
and cron repeats the process periodically.
25. What Happens When the Trusted IP Changes?
Assume the hostname initially resolves to:
203.0.113.20
The firewall contains:
203.0.113.20
Later DNS changes to:
203.0.113.21
During the next cron run:
dig +short A trusted.example.com
returns:
203.0.113.21
The script rebuilds the trusted set and the new firewall contains:
203.0.113.21
New SSH connections from the previous address can then be rejected.
26. A Limitation of head -n1
This command:
dig +short A "$TRUSTED_HOST" |
grep ... |
head -n1
uses only the first IPv4 address returned by DNS.
That is perfectly fine if the hostname normally has one address.
However, services using:
- multiple A records
- round-robin DNS
- load balancing
- CDN addresses
may return several IPs.
For those environments, the script should collect every returned address and add all of them to an nftables set rather than selecting only the first result.
27. Example Firewall Logic
The final policy can be visualized like this:
Internet
|
v
+---------------+
| nftables |
+---------------+
|
+-----------------+-----------------+
| | |
v v v
SSH 122 HTTPS 443 ICMP
| | |
Trusted IP? Trusted IP? Echo Request?
/ \ / \ |
Yes No Yes No Trusted IP?
| | | | / \
ACCEPT DROP ACCEPT DROP Yes No
| |
ACCEPT DROP
This dramatically reduces exposure of the protected services.
28. Security Improvements Compared with the Original Version
There are several changes I recommend compared with a simple firewall script.
Resolve DNS before flushing rules
Bad:
nft flush ruleset
dig ...
Better:
dig ...
validate IP
nft ...
A temporary DNS failure should not remove your firewall.
Use root consistently
Instead of:
sudo nft flush ruleset
nft add ...
run the entire script as root:
sudo /usr/local/sbin/dynamic-nftables.sh
The script itself checks:
if [[ $EUID -ne 0 ]]; then
exit 1
fi
Do not open UDP for SSH
OpenSSH normally needs only:
TCP
not UDP.
Do not drop all ICMP blindly
Restrict ping while keeping useful network error messages.
Use nftables sets
Instead of repeating the same source address in many rules:
ip saddr @trusted_v4
is easier to maintain.
Use flock
This prevents simultaneous cron executions.
Keep logs
Cron output is stored in:
/var/log/dynamic-nftables.log
which makes troubleshooting much easier.
29. Additional SSH Hardening
Firewall restrictions should be combined with proper SSH configuration.
For an Internet-facing server I also recommend:
SSH public-key authentication
Disable direct root login
Disable password login when possible
Keep OpenSSH updated
Use strong private-key protection
Limit which users can log in
Typical /etc/ssh/sshd_config options may include:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
After changing SSH configuration, validate it before restarting:
sudo sshd -t
If the test succeeds, reload the SSH service according to your distribution.
Do not disable password authentication until you have confirmed that public-key authentication works correctly.
30. Do Not Rely on a Non-Standard SSH Port Alone
Moving SSH from:
22
to something like:
122
can reduce automated login attempts and log noise.
However, a port scanner can still discover it.
Think of a custom SSH port as reducing background noise, not providing authentication or serious access control.
The real protection comes from combining:
Firewall allowlisting
+
SSH keys
+
No root login
+
No password authentication
+
System updates
31. Useful Commands
Display the complete ruleset:
sudo nft list ruleset
Display only the input chain:
sudo nft list chain inet filter input
Run the script manually:
sudo /usr/local/sbin/dynamic-nftables.sh
Check script syntax:
sudo bash -n /usr/local/sbin/dynamic-nftables.sh
Check the resolved address:
dig +short A trusted.example.com
Watch the cron log:
sudo tail -f /var/log/dynamic-nftables.log
Display root cron jobs:
sudo crontab -l
32. Final Notes
This approach is useful when access to an Internet-facing service should follow a trusted dynamic IP address.
The overall design is simple:
Dynamic DNS
↓
Resolve with dig
↓
Validate the IP
↓
Generate nftables rules
↓
Allow trusted source
↓
Block untrusted access
↓
Repeat with cron
The most important lesson is not the exact port numbers.
It is the firewall strategy:
Allow what is trusted first.
Block the protected service second.
Resolve dynamic addresses before modifying the firewall.
Do not expose unnecessary protocols.
Do not blindly block essential ICMP traffic.
With a small Bash script, nftables, and cron, a Linux server can automatically maintain a source-IP allowlist without manually updating firewall rules every time a trusted dynamic IP changes.
Disclaimer
This article is provided as a reference configuration.
Firewall requirements vary between servers, distributions, hosting providers, Docker environments, VPN configurations, IPv6 deployments, and network architectures.
Always test firewall changes while retaining console or recovery access to the server.
The IP addresses used in this article are documentation examples and should be replaced with addresses appropriate for your own environment.
