Protecting SSH, HTTPS, and ICMP with nftables, Dynamic DNS, and Cron

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:

  1. Keep the current SSH session connected.
  2. Open a second terminal.
  3. Try connecting to SSH again.
  4. Verify that your trusted address works.
  5. Verify the firewall rules.
  6. 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.