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:
Set a root password.
Remove anonymous users.
Disallow root login remotely.
Remove test databases.
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.
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.
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.
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
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 || trueCode 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.
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.
If you include log output in public writing, mask hostname and PIDs:
Dec 18 06:47:54 <host> systemd[1]: Stoppinglongview.service...
Dec 18 06:47:54 <host> longview[<PID>]: StoppingLongviewAgent...
Dec 18 06:47:54 <host> systemd[1]: Startedlongview.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
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.
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.
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 Technique
Description
T1068
Exploitation for Privilege Escalation
T1190
Exploit 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)
Type
Value
Attacker IP
193.26.115.195 (Netherlands)
Remote Host
74.194.191.52
Malicious Script
rondo.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 IPssudo 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 necessarysudo apt update && sudo apt install --only-upgrade bash env x='() { :; }; echo vulnerable' bash -c "echo test" # should only print: test
Inspect for any secondary impactsudo 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.
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.
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.
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:
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.
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 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.
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.
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.
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:
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
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
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.
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
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.
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).
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.
This performs the same transfer in one step without creating a dump file on disk.
Summary
Step
Action
Command
1
Dump database on source
pg_dump -Fc -f /tmp/myapp.dump myapp
2
Copy to destination
scp /tmp/myapp.dump user@pg-dst:/tmp/
3
Create DB on destination
createdb -O myapp_user myapp
4
Restore
pg_restore -d myapp /tmp/myapp.dump
5
Verify
psql -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.
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.
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.
interfacedocker0ACCEPT;
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.