Setting Up a Simple Firewall on Debian 12 with Ferm

There are many ways to configure a firewall in Linux. Some people prefer ufw, others go straight to iptables or nftables. But in between those layers of complexity, there’s a small, quiet tool that often goes unnoticed: Ferm, For Easy Rule Making.

The name says it all.

I like Ferm because it doesn’t try to be fancy or abstract. It simply translates human-readable logic into firewall rules that the system understands. It’s one configuration file, clean and transparent, no need to remember long chains of iptables commands that make your head spin.

Why Choose Ferm

On most of my Debian 12 servers, my security model is simple: block everything by default and open only specific ports from trusted sources. I want something that’s explicit, maintainable, and not dependent on background daemons. Ferm gives me exactly that.

With Ferm, writing firewall rules feels like writing a scrip, structured, readable, and explainable. There are no “zones” or “presets,” just clear logical rules that you define yourself.

Example: Allowing Access from Specific IPs

Let’s say we have a Debian 12 server with public IP 203.0.113.50.
We want to allow SSH (port 22) only from the admin laptop 203.0.113.20,
and allow HTTP/HTTPS (80 and 443) only from the office network 198.51.100.0/24.
Everything else should be denied.

First, install Ferm:

sudo apt update
sudo apt install -y ferm
sudo systemctl enable ferm

Then create /etc/ferm/ferm.conf:

@def $trusted_http = ( 198.51.100.0/24 );   # office network
@def $trusted_ssh  = ( 203.0.113.20 );      # admin laptop

table filter {
  chain INPUT {
    policy DROP;
    interface lo ACCEPT;
    mod state state (ESTABLISHED RELATED) ACCEPT;
    proto icmp ACCEPT;

    proto tcp dport 22  saddr ($trusted_ssh)  ACCEPT;
    proto tcp dport (80 443) saddr ($trusted_http) ACCEPT;
  }

  chain FORWARD { policy DROP; }
  chain OUTPUT  { policy ACCEPT; }
}

Save and apply:

sudo ferm -n /etc/ferm/ferm.conf    # syntax check
sudo systemctl restart ferm
Code language: PHP (php)

Within seconds, your firewall is active.
Any connection outside those IPs will be quietly dropped.

A Small Philosophy Behind the Configuration

Firewalls don’t need to be complicated. They’re like the fence around your house — strong enough to keep things safe, but with gates you choose to open. Ferm lets you design that fence in a language that makes sense to you.

What I like most about this approach is not just the security — it’s the intentionality. Every open port becomes a conscious choice, not a side effect of software defaults.

Testing and Verification

After applying the configuration, it’s always good to verify.
From your trusted admin laptop, check if SSH and web access still work:

ssh user@203.0.113.50
curl -I http://203.0.113.50
curl -I https://203.0.113.50
Code language: CSS (css)

Then, from any untrusted machine, try to connect to those same ports. You should see no response — the packets are silently dropped.

If you want a more detailed check, use nmap from another host:

nmap -Pn 203.0.113.50
Code language: CSS (css)

Only ports 22, 80, and 443 should appear as open — and only if you’re scanning from the allowed IPs.

This simple test closes the loop: your firewall is active, predictable, and fully under your control.

Closing Thoughts

In a world of containers, proxies, and cloud layers everywhere, having one simple, dependable firewall at the system level still matters. Ferm isn’t popular, and maybe that’s its charm. It’s lightweight, stable, and honest about what it does.

Sometimes the best tools aren’t the most powerful ones, but the ones that stay true to their purpose.
Ferm is one of those.

Creating a Secure Non-Interactive Jenkins User on Debian 12

In continuous integration environments, it’s often necessary to run Jenkins agents or scripts securely under a dedicated system account. On Debian 12, this means creating a non-interactive user, one that can authenticate via SSH keys but cannot log in interactively or hold a password.

This guide walks through setting up a minimal, hardened Jenkins user that works seamlessly with CI/CD pipelines, remote triggers, and automation processes, without exposing unnecessary access surfaces.

Why a “No-Shell” Jenkins User Matters

When Jenkins connects to a build agent or deploys to a target host, it only needs permission to execute controlled tasks, not a full login environment.

Granting Jenkins a shell (such as /bin/bash) or password introduces unnecessary risk. It provides an attack surface for privilege escalation or accidental misuse.

By setting the shell to /usr/sbin/nologin and removing password authentication, we achieve:

  • No console access
  • Reduced attack surface
  • Clear isolation of automation tasks
  • Compliance-friendly identity control

Step-by-Step Setup on Debian 12

Run the following commands as root or with sudo.

1. Create the user without shell or password

sudo useradd -m -d /var/lib/jenkins -s /usr/sbin/nologin jenkins
sudo passwd -d jenkins
Code language: JavaScript (javascript)

This creates /var/lib/jenkins as the home directory, removes any password, and prevents login attempts.

2. Prepare the SSH directory

sudo mkdir -p /var/lib/jenkins/.ssh
sudo chmod 700 /var/lib/jenkins/.ssh
sudo chown jenkins:jenkins /var/lib/jenkins/.ssh
Code language: JavaScript (javascript)

The .ssh directory will store public keys used for Jenkins agent connections or automation scripts.

3. Add your Jenkins RSA public key

echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ...your_public_key..." | \
sudo tee /var/lib/jenkins/.ssh/authorized_keys
Code language: PHP (php)

Then fix permissions:

sudo chmod 600 /var/lib/jenkins/.ssh/authorized_keys
sudo chown jenkins:jenkins /var/lib/jenkins/.ssh/authorized_keys
Code language: JavaScript (javascript)

4. Verify access

To confirm the setup works, test with:

ssh -i ~/.ssh/jenkins_rsa jenkins@your_server_ip
Code language: JavaScript (javascript)

You should see:

This account is currently not available.

That message confirms authentication succeeded, but the user cannot open a shell — which is the desired behavior.

Checking if the User Already Exists

Before creating the user, check if it already exists:

id jenkins

or

getent passwd jenkins

or

grep jenkins /etc/passwd

If you’d like to automate it, here’s an idempotent one-liner:

id jenkins &>/dev/null || sudo useradd -m -d /var/lib/jenkins -s /usr/sbin/nologin jenkins
Code language: JavaScript (javascript)

Optional Security Extensions

For even tighter control, consider restricting how the key can be used. You can add these options to authorized_keys:

command="/usr/local/bin/jenkins-agent" ssh-rsa AAAA...
Code language: JavaScript (javascript)

This forces the key to only execute Jenkins’ agent process.

Or restrict the source IPs allowed to use the key:

from="203.128.87.0/24" ssh-rsa AAAA...
Code language: JavaScript (javascript)

You can also audit file ownership to ensure no permission drift:

sudo ls -ld /var/lib/jenkins/.ssh
sudo ls -l /var/lib/jenkins/.ssh/authorized_keys
Code language: JavaScript (javascript)

Forward-Thinking: Infrastructure Hygiene

Modern DevOps pipelines rely on predictable, secure, and auditable service accounts. Treating the Jenkins user as a non-interactive identity, rather than a pseudo-admin, supports:

  • Easier migration toward zero-trust infrastructure
  • Seamless integration with key-based CI/CD triggers
  • Stronger adherence to least-privilege and compliance standards

Security should not be about locking everything down, but about defining precise, purposeful access boundaries that scale with your infrastructure.

Conclusion

Every credential, every process, every account must exist for a reason — and nothing more.

Creating a passwordless, no-shell Jenkins user on Debian 12 keeps automation clean, minimal, and secure by design — the way infrastructure should evolve.

Running Redash with Docker Compose on Debian 12

Redash remains one of the most approachable open-source tools for building dashboards, sharing SQL queries, and collaborating on data.

While the project is no longer actively maintained upstream, the community builds (10.x) are still widely used for internal analytics.

In this article, I’ll show how to bring up Redash quickly on Debian 12 using Docker Compose. We’ll focus on the two files that matter most: the .env and the docker-compose.yaml.

This assumes you already have Docker Engine and Docker Compose v2 installed.

1. Directory Layout

Create a workspace for your Redash stack:

sudo mkdir -p /var/app/docker/redash-example
cd /var/app/docker/redash-example
mkdir -p data/postgres data/redis logs
Code language: JavaScript (javascript)

This folder will hold:

  • data/postgres → PostgreSQL data files
  • data/redis → Redis persistence
  • logs → container logs if needed
  • .env and docker-compose.yaml

2. Environment File (.env)

The .env file holds all credentials and configuration. Redash services will consume these automatically.

# =========================
# Redash on Docker (.env)
# =========================

# ---- Postgres
POSTGRES_USER=redash
POSTGRES_PASSWORD=change_me_strong_pw
POSTGRES_DB=redash

# ---- Redis
REDIS_PASSWORD=another_strong_pw

# ---- Redash image
REDASH_IMAGE=redash/redash:10.1.0.b50633

# ---- Core secrets
REDASH_SECRET_KEY=REPLACE_WITH_64B_BASE64
REDASH_COOKIE_SECRET=REPLACE_WITH_32B_BASE64

# ---- App behavior
REDASH_RATELIMIT_ENABLED=true
REDASH_LOG_LEVEL=INFO
REDASH_MULTI_ORG=false
REDASH_WEB_WORKERS=4
REDASH_SCHEDULED_QUEUE_NAME=scheduled
REDASH_ADHOC_QUERY_TIME_LIMIT=600
REDASH_QUERY_TIME_LIMIT=1800
REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN=*

# ---- External URL
REDASH_HOST=http://localhost:50001

# ---- SMTP / Email
REDASH_MAIL_SERVER=smtp-relay.local
REDASH_MAIL_PORT=587
REDASH_MAIL_USE_TLS=true
REDASH_MAIL_USE_SSL=false
[email protected]
REDASH_MAIL_PASSWORD=supersecret
REDASH_MAIL_DEFAULT_SENDER="Redash <[email protected]>"

# =========================
# NOTE:
# Do NOT put REDASH_DATABASE_URL or REDASH_REDIS_URL here.
# They are built dynamically in docker-compose.yaml.
# =========================
Code language: PHP (php)

Tip, generate strong random secrets with:

openssl rand -base64 64   # for REDASH_SECRET_KEY
openssl rand -base64 32   # for REDASH_COOKIE_SECRET
Code language: PHP (php)

3. Docker Compose File (docker-compose.yaml)

This Compose file brings up five containers: PostgreSQL, Redis, the Redash server, a worker, and a scheduler.
Since the host already has Nginx, we don’t run Nginx inside the stack — instead we bind the server on localhost:50001.

name: redash-example

services:
  postgres:
    image: postgres:14-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
    restart: unless-stopped
    volumes:
      - ./data/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "PING"]
      interval: 10s
      timeout: 5s
      retries: 10

  server:
    image: ${REDASH_IMAGE}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    env_file: .env
    environment:
      REDASH_DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
      REDASH_REDIS_URL: "redis://:${REDIS_PASSWORD}@redis:6379/0"
      REDASH_SECRET_KEY: ${REDASH_SECRET_KEY}
      REDASH_COOKIE_SECRET: ${REDASH_COOKIE_SECRET}
      REDASH_LOG_LEVEL: ${REDASH_LOG_LEVEL:-INFO}
      REDASH_HOST: ${REDASH_HOST}
      GUNICORN_CMD_ARGS: "--timeout 120 --graceful-timeout 120 --workers ${REDASH_WEB_WORKERS:-2}"
    command: server
    ports:
      - "127.0.0.1:50001:5000"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://localhost:5000/healthcheck"]
      interval: 10s
      timeout: 5s
      retries: 30

  worker:
    image: ${REDASH_IMAGE}
    depends_on:
      server:
        condition: service_started
    restart: unless-stopped
    env_file: .env
    environment:
      REDASH_DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
      REDASH_REDIS_URL: "redis://:${REDIS_PASSWORD}@redis:6379/0"
      REDASH_SECRET_KEY: ${REDASH_SECRET_KEY}
      QUEUES: "queries,scheduled,celery,schemas,periodic,emails,default"
      WORKERS_COUNT: "2"
    command: worker

  scheduler:
    image: ${REDASH_IMAGE}
    depends_on:
      server:
        condition: service_started
    restart: unless-stopped
    env_file: .env
    environment:
      REDASH_DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
      REDASH_REDIS_URL: "redis://:${REDIS_PASSWORD}@redis:6379/0"
      REDASH_SECRET_KEY: ${REDASH_SECRET_KEY}
      QUEUES: "scheduled,celery"
    command: scheduler
Code language: PHP (php)

4. Initialize Redash

Bring the services up:

docker compose pull
docker compose up -d postgres redis
docker compose run --rm server create_db
docker compose up -d

Check logs:

docker compose logs -f server

Visit: http://localhost:50001

5. Integrate with Host Nginx

If your Debian host already runs Nginx for other sites, just add a vhost:

server {
  listen 443 ssl http2;
  server_name analytics.example.com;

  ssl_certificate /etc/letsencrypt/live/analytics.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/analytics.example.com/privkey.pem;

  location / {
    proxy_pass         http://127.0.0.1:50001;
    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;
  }
}
Code language: PHP (php)

Reload:

sudo nginx -t && sudo systemctl reload nginx

Now Redash is available at https://analytics.example.com.

6. Sending Email

Test SMTP from inside the container:

docker compose run --rm server manage send_test_mail

If configured correctly, Redash will send a test message from the REDASH_MAIL_DEFAULT_SENDER.

Closing Notes

That’s all it takes: a .env for configuration, and a docker-compose.yaml for orchestration.

This pattern keeps the stack self-contained while leaving room for scaling or migrating later (for example, moving Postgres to RDS, or Redis to a managed service).

If you already run Nginx on your host, exposing Redash only to 127.0.0.1:50001 is the cleanest way to avoid conflicts and keep your surface minimal.

Happy dashing!!

Fixing Grafana Repository GPG Error on Debian 12

It happened to me on a quiet evening, running the usual sudo apt update on a Debian 12 server that powers Grafana for dashboards I rely on daily.

Instead of the usual green lines of updates, I was greeted by a red warning.

The following signatures were invalid: EXPKEYSIG 963FA27710458545 Grafana Labs <[email protected]>Code language: HTML, XML (xml)

For a moment, it looked like the repository had gone bad, but in reality this was Debian doing its job.

The key Grafana used to sign its repository had expired.

Without a valid signature, Debian simply refuses to fetch updates. That is a feature, not a bug, better to stop than to trust something potentially unsafe.

The fix is not about forcing apt to ignore the error. It is about refreshing trust, by pulling down Grafana’s new signing key and configuring Debian to use it properly.

On Debian 12, the recommended approach is to store keys in /etc/apt/keyrings and explicitly link them in the repository definition with the signed-by option.

The process goes step by step, create the keyrings directory if it does not exist, remove any stale Grafana key, download the latest one, convert it to the format Debian understands, and then rewrite the repository definition so that it points directly to the new key.

Once that’s done, a simple apt update brings Grafana packages back to life without errors.

One-Shot Script to Refresh the Key

For those who want everything in one go, here is a single script that handles the full refresh:

# 1) create keyrings folder (if not there yet)
sudo mkdir -p /etc/apt/keyrings

# 2) remove old key (safe and ok if not there)
sudo rm -f /etc/apt/keyrings/grafana.gpg

# 3) get new key from Grafana and dearmor into keyring system
wget -q -O - https://apt.grafana.com/gpg.key \
  | gpg --dearmor \
  | sudo tee /etc/apt/keyrings/grafana.gpg > /dev/null

# 4) make sure repo definition use signed-by (stable + beta if you need that one)
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" \
 | sudo tee /etc/apt/sources.list.d/grafana.list > /dev/null

# (optional) add beta if you need one
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com beta main" \
 | sudo tee -a /etc/apt/sources.list.d/grafana.list > /dev/null

# 5) Refresh index
sudo apt update
Code language: PHP (php)

Once the script finishes, the error disappears and you are back in business. If you want to be extra cautious, you can inspect the fingerprint of the key with:

gpg --show-keys --with-fingerprint /etc/apt/keyrings/grafana.gpgCode language: JavaScript (javascript)

This way you can verify it against Grafana’s official fingerprint and be certain the key is legitimate. Occasionally, the problem is not on your side at all, if Grafana lets their repository metadata expire, you will see the same error even after refreshing the key.

In those rare cases, the options are to wait for them to push a fix or temporarily disable the repository. Most of the time, simply refreshing the key is enough.

There’s a certain rhythm to maintaining servers, small errors surface, you patch them, and trust is re-established.

This little Grafana key rotation was just another reminder that Debian’s security model works. It is strict for a reason, and the solution is not to bypass it, but to align with it. In the end, keeping monitoring systems secure is as important as the dashboards themselves.

Quick Fix Commands Cheat Sheet

If you only need the essential commands, here is the short version:

sudo mkdir -p /etc/apt/keyrings
sudo rm -f /etc/apt/keyrings/grafana.gpg
wget -q -O - https://apt.grafana.com/gpg.key \
  | gpg --dearmor \
  | sudo tee /etc/apt/keyrings/grafana.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" \
 | sudo tee /etc/apt/sources.list.d/grafana.list > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com beta main" \
 | sudo tee -a /etc/apt/sources.list.d/grafana.list > /dev/null
sudo apt update
Code language: PHP (php)

Host Your Own VPN using WireGuard

WireGuard is often chosen because it’s modern, lightweight, and fast compared to older VPN protocols. Its codebase is much smaller than OpenVPN or IPsec, which makes it easier to audit and maintain, reducing the risk of hidden vulnerabilities.

This simplicity also translates to better performance with lower latency and faster connection times.

Another reason to use WireGuard is its strong cryptographic design. It uses state-of-the-art encryption algorithms by default, without exposing users to the complexity of choosing among outdated or insecure options.

This makes it both secure and user-friendly, since the defaults are already optimized for safety and speed.

This WireGuard, integrates well with Linux and is available on most platforms, from mobile to servers. Its minimal configuration and compatibility with tools like systemd or Docker make it appealing for both personal VPN setups and enterprise-scale deployments.

In short, it balances speed, security, and simplicity better than most alternatives.

YouTube Tutorial

This video tutorial provides a comprehensive, step-by-step guide to setting up your own self-hosted VPN server using a Virtual Private Server (VPS).

The VPN server is configured using WireGuard, a lightweight, open-source VPN protocol known for its simplicity and speed.

The video covers everything from selecting the server location and configuring server specifications to installing WireGuard using a popular community script, generating VPN client profiles, and securely connecting devices like a MacBook to the newly created VPN server.

The tutorial emphasizes privacy and control over data by avoiding reliance on commercial VPN providers. Finally, the presenter teases future videos on enhancing server security through firewall setup, SSH key creation, and disabling root and password logins.

Kudos for the YouTuber.

Using 7-Zip on Debian 12: Secure, Recursive File Compression

If you’ve been working with Debian 12, chances are you’ve already used tar or zip for packaging files. They work fine, but when you need smaller archives and stronger protection, nothing beats 7-Zip. The .7z format not only compresses better, it also supports AES-256 encryption and can even hide file names.

This guide will walk you through installing and using 7-Zip on Debian 12, show how recursive compression works, and explain why .7z is safer than .zip.

1. Installing 7-Zip on Debian 12

Update your package list and install the full package:

sudo apt update
sudo apt install p7zip-full p7zip-rar -y
  • p7zip-full provides the 7z command with full archive support
  • p7zip-rar is optional, adds .rar support

Verify the installation:

which 7z

You should see /usr/bin/7z.

2. The Basics: Compress, Extract, List

The 7z tool is straightforward:

  • Compress files or folders (recursive by default): 7z a archive.7z /path/to/folder
  • Extract an archive: 7z x archive.7z
  • List archive contents: 7z l archive.7z
  • Test archive integrity: 7z t archive.7z

When you compress a folder, 7-Zip automatically goes through all subfolders recursively. No extra flag is needed.

3. Why .7z Is Safer and Stronger Than .zip

Most people are familiar with .zip, but .7z offers significant security and efficiency advantages:

  • Encryption strength: .7z uses AES-256, a modern, industry-standard encryption method. By contrast, .zip often defaults to ZipCrypto, which is weak and can be brute-forced quickly. Even when .zip uses AES, support across platforms is inconsistent.
  • Hidden file names: With .7z, the -mhe option encrypts archive headers. This means even the file and folder names are hidden until the correct password is entered. .zip cannot do this — file names are always visible.
  • Compression ratio: .7z generally produces smaller files than .zip, saving space and bandwidth.
  • Cross-platform reality: .zip is universally supported (Windows and macOS can open without extra tools). .7z may require installing 7-Zip or PeaZip, but the tradeoff is much better security.

In short: use .7z when security and efficiency matter, and .zip only when compatibility is critical.

4. Safest Way to Archive a Folder

To compress a folder securely, recursively, and with verbose output:

7z a -t7z -p -mhe=on -bb3 archive.7z /path/to/folder

What this does:

  • -t7z explicitly uses the 7-Zip format
  • -p prompts for a password (not stored in shell history)
  • -mhe=on encrypts file headers so names and folders are hidden
  • -bb3 gives maximum verbosity, showing full progress
  • archive.7z is the output file
  • /path/to/folder is the folder you want to compress (all subfolders included automatically)

You’ll be prompted for a password and confirmation before the process starts.

5. Extracting a Secure Archive

To restore the archive:

7z x archive.7z -bb3
Code language: CSS (css)

You’ll be asked for the password before extraction begins.

6. Example in Action

Archiving /var/app/local/data into backup.7z:

7z a -t7z -p -mhe=on -bb3 backup.7z /var/app/local/data
Code language: JavaScript (javascript)

Extracting later:

7z x backup.7z -bb3
Code language: CSS (css)

7. Quick 7-Zip Cheat Sheet

A handy reference for common commands:

# Create archive (recursive by default)
7z a archive.7z /path/to/folder

# Create archive with password + hidden names
7z a -t7z -p -mhe=on archive.7z /path/to/folder

# Extract archive
7z x archive.7z

# List archive contents
7z l archive.7z

# Test archive integrity
7z t archive.7z
Code language: PHP (php)

8. Wrapping Up

On Debian 12, 7-Zip is the safest way to compress and protect your data. It compresses recursively by default, encrypts contents with AES-256, and with -mhe even hides file and folder names.

  • Use .7z when you care about security and compression.
  • Fall back to .zip only when you need universal compatibility.

With just a few commands, you can ensure your data is safe, private, and portable.

Fail2ban on Debian 12, The Friendly, no‑nonsense setup

Fail2ban monitors your service logs for repeated failures and blocks offending IPs using your firewall. This guide explains the concept and gives copy paste jails for SSH on 22, HTTP on 80 or 443, and MySQL or MariaDB on 3306.

How Fail2ban works

  • Reads logs from services (for example: /var/log/auth.log, web server error or access logs, MySQL error log).
  • Filters match suspicious lines.
  • If failures exceed maxretry within findtime, Fail2ban bans the source IP for bantime via nftables on Debian 12.

Key terms:

  • jail: the rule that ties a filter and an action to specific logs and ports
  • filter: regex rules that match bad events in logs
  • action: how to ban (we use nftables-multiport)

Install Fail2ban

sudo apt update
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
sudo systemctl status fail2ban

Install rsyslog

Fail2ban only works if it can see login failures in your logs. On Debian 12, the system journal (journald) already captures them, but many Fail2ban jails still expect a traditional log file like /var/log/auth.log. To keep things simple, install rsyslog so those logs are written out automatically:

sudo apt install rsyslog
sudo systemctl enable --now rsyslog

Base configuration

Do not edit jail.conf. Create your own overrides.

/etc/fail2ban/jail.local

[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1
banaction = nftables-multiport
logtarget = /var/log/fail2ban.log
Code language: PHP (php)

Reload after changes:

sudo fail2ban-client reload

Jail 1, protect SSH on port 22

/etc/fail2ban/jail.d/sshd.local

[sshd]
enabled  = true
port     = 22
backend  = auto
logpath  = /var/log/auth.log
maxretry = 5
findtime = 10m
bantime  = 1h
Code language: JavaScript (javascript)

Check status:

sudo fail2ban-client status sshd
sudo tail -f /var/log/fail2ban.log
Code language: JavaScript (javascript)

Jail 2, protect HTTP on ports 80 and 443

Fail2ban reacts to patterns in logs. Two common web auth cases are below. Use only the one that applies to your stack.

Nginx basic auth:
/etc/fail2ban/jail.d/nginx-http-auth.local

[nginx-http-auth]
enabled  = true
port     = http,https
filter   = nginx-http-auth
logpath  = /var/log/nginx/error.log
maxretry = 3
findtime = 10m
bantime  = 1h
Code language: JavaScript (javascript)

Apache basic auth:
/etc/fail2ban/jail.d/apache-auth.local

[apache-auth]
enabled  = true
port     = http,https
filter   = apache-auth
logpath  = /var/log/apache2/error.log
maxretry = 3
findtime = 10m
bantime  = 1h
Code language: JavaScript (javascript)

Tip: run ls /etc/fail2ban/filter.d to see other web filters such as nginx-noscript or apache-badbots, then point a jail at the filter and the correct log path.

Jail 3, watch MySQL or MariaDB on port 3306

Best practice is not to expose 3306 publicly. Bind to localhost or a private network and restrict with your firewall. If you must watch for brute force on 3306:

/etc/fail2ban/jail.d/mysqld-auth.local

[mysqld-auth]
enabled  = true
port     = 3306
filter   = mysqld-auth
logpath  = /var/log/mysql/error.log
maxretry = 3
findtime = 10m
bantime  = 6h
Code language: JavaScript (javascript)

Confirm the error log path inside MariaDB or MySQL:

SHOW VARIABLES LIKE 'log_error';
Code language: JavaScript (javascript)

If your DB logs only to the journal, you can switch a jail to use systemd:

[mysqld-auth]
enabled   = true
port      = 3306
filter    = mysqld-auth
backend   = systemd
journalmatch = _SYSTEMD_UNIT=mariadb.service + _COMM=mysqld
maxretry  = 3
findtime  = 10m
bantime   = 6h
Code language: JavaScript (javascript)

Optional, recidive jail for repeat offenders

/etc/fail2ban/jail.d/recidive.local

[recidive]
enabled  = true
logpath  = /var/log/fail2ban.log
bantime  = 1d
findtime = 1d
maxretry = 5
Code language: JavaScript (javascript)

Apply and verify

Check configuration syntax:

sudo fail2ban-client -d

Reload Fail2ban:

sudo fail2ban-client reload

List jails and show bans:

sudo fail2ban-client status
sudo fail2ban-client status sshd

Unban a specific IP:

sudo fail2ban-client set sshd unbanip 203.0.113.45
Code language: CSS (css)

Check nftables sets for banned IPs:

sudo nft list ruleset | grep -A3 'f2b'
Code language: PHP (php)

Troubleshooting

  • Filters available:
ls /etc/fail2ban/filter.d
  • Test a filter against a log:
sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
Code language: JavaScript (javascript)
  • Reverse proxy or CDN:
    Ensure your web server logs the real client IP, not the proxy, otherwise you will ban the proxy’s IP.
  • Dockerized services:
    Make sure containers write logs to files on the host or to journald so Fail2ban can read them.

Why we still add dbAdmin even when a user already has readWrite on MongoDB 8 – Debian 12?

In my earlier post, “How to set up MongoDB 8 master–slave on Debian 12 (Bookworm)”, we ended with a working replica set and basic authentication.

Now comes the daily-ops reality: your app user needs to write documents, but your ops tasks also need to tweak schema rules, adjust TTLs, rename collections, or rebuild indexes during a migration.

This is the point where many teams discover that readWrite alone isn’t enough and why we deliberately grant a second role, dbAdmin, to the same database user.

What readWrite actually covers

readWrite is a data-plane role. It grants the ability to read and modify data in non-system collections of the target database.

Practically, that includes common CRUD operations and routine developer needs like creating collections and (yes) building indexes things your application does as part of normal operation.

The takeaway: readWrite is exactly right for application traffic and developer CRUD.

It intentionally does not include higher-risk administration actions that can reshape metadata or the database itself.

What dbAdmin adds and why it matters in production

dbAdmin is an administration role scoped to a single database. It allows schema-level and metadata operations: modifying collection options, running validation, profiling, compacting, renaming, dropping, and managing indexes beyond simple creation.

Concretely, dbAdmin adds privileges like collMod, renameCollectionSameDB, dropCollection, dropIndex, reIndex, validate, createSearchIndexes, and profiling controls the kinds of operations you need during controlled maintenance windows, migrations, and incident response.

One especially relevant example is collMod. If you want to tighten document validation, flip a collection to timeseries, adjust a TTL, or change index expireAfterSeconds via collMod, the server requires the collMod privilege supplied by dbAdmin.

Typical scenarios where readWrite is not enough

When you hit any of these, you’ll appreciate having dbAdmin on the same user (or a separate ops-only user):

  • Tightening schema validation or adjusting TTLs (collMod).
  • Re-indexing after a migration (reIndex) or dropping obsolete indexes (dropIndex).
  • Renaming a collection for a blue/green rollout (renameCollectionSameDB).
  • Running deeper integrity checks and stats (validate, dbStats, collStats).

But doesn’t dbOwner already include both?

Yes, dbOwner on a database bundles readWrite, dbAdmin, and userAdmin. It’s convenient, but it’s also far more authority than most application users should have. Sticking to readWrite + dbAdmin is a tighter, least-privilege stance for day-to-day ops without drifting into user/role management powers.

Forward-looking, keep your roles split, and customize when needed

Granting two roles to the same user is intentional separation of concerns, the app keeps its data-plane capabilities (readWrite), while ops tasks can be performed safely and auditable via dbAdmin.

For teams with stricter controls, you can also create a custom role that inherits readWrite and only the admin actions you actually need (for example, just collMod), instead of the full dbAdmin surface.

Practical examples (you can run these in mongosh)

Create a user that has both roles on a single database:

use your_database_name
db.createUser({
  user: "thisisuser",
  pwd:  "thisisupersecretpass",
  roles: [
    { role: "readWrite", db: "your_database_name" },
    { role: "dbAdmin",   db: "your_database_name" }
  ]
})
Code language: CSS (css)

Tighten a TTL on an index with collMod (requires dbAdmin):

db.runCommand({
  collMod: "events",
  index: { name: "expires_at_1", expireAfterSeconds: 3600 } // 1 hour
})
Code language: JavaScript (javascript)

Rename a collection during a deploy (requires dbAdmin):

db.adminCommand({
  renameCollection: "your_database_name.events",
  to: "your_database_name.events_2025q3"
})
Code language: CSS (css)

Create a custom “minimal admin” role that keeps readWrite but only adds collMod:

use your_database_name
db.createRole({
  role: "rw_with_collmod_only",
  privileges: [
    { resource: { db: "your_database_name", collection: "" }, actions: [ "collMod" ] }
  ],
  roles: [ { role: "readWrite", db: "your_database_name" } ]
})

// Optionally grant it:
db.grantRolesToUser("thisisuser", [ { role: "rw_with_collmod_only", db: "your_database_name" } ])
Code language: JavaScript (javascript)

This pattern gives you just-enough power for schema tuning without unlocking the entire dbAdmin toolbox.

Closing thought

On fresh deployments it’s tempting to throw dbOwner at a user and move on. Resist that. For long-lived systems, especially on Debian 12 with MongoDB 8, keep data plane (readWrite) and admin plane (dbAdmin) clearly separated, and fall back to a custom role when you can narrow privileges further.

That balance keeps your app productive while preserving the guardrails you’ll need when future migrations, validations, and index clean-ups arrive.

Install and Setup Atmoz SFTP Server on Debian 12 Using Docker

If you need a simple, secure, and straightforward SFTP server, Atmoz SFTP is one of the easiest solutions available.

It runs inside Docker, requires minimal configuration, and is secure by default, no fiddly chroot setup that can often be error-prone or introduce security risks.

In this guide, we’ll install and run Atmoz SFTP on Debian 12 (Bookworm) using Docker Compose.
If you haven’t installed Docker and Docker Compose yet, follow my earlier guide:

Why Choose Atmoz SFTP?

Unlike traditional FTP, SFTP runs over SSH, providing encryption by default, with Atmoz SFTP, you get:

  • Secure by default, only SFTP over SSH, no plaintext FTP.
  • Dockerized simplicity, no OS-level tinkering with OpenSSH configs.
  • Quick setup, create a user, map a directory, run the container.
  • No chroot headaches, the container handles isolation cleanly.

Step 1. Prepare Your Environment Variables

We’ll use a .env file to store credentials, port settings, and the local path to be mounted into the container.
This makes it easier to manage and avoids hardcoding sensitive values into the Compose file.

Create a file named .env:

nano .envCode language: CSS (css)

Add the following content (customize to your needs):

SFTP_USER=example.user
SFTP_PASSWORD=example.pass.1234
SFTP_PORT=22888
SFTP_PATH=/mnt/example.host.pathCode language: JavaScript (javascript)

Explanation:

  • SFTP_USER → The username for SFTP login.
  • SFTP_PASSWORD → Strong password for authentication.
  • SFTP_PORT → External port to access SFTP (mapped to container’s port 22).
  • SFTP_PATH → Local folder path that will be mounted inside the container.

Step 2. Create the Docker Compose File

Next, create a docker-compose.yaml file:

nano docker-compose.yamlCode language: CSS (css)

Paste the following configuration:

services:
  sftp:
    image: atmoz/sftp:latest
    container_name: atmoz-sftp-server-1
    mem_limit: 2g         # Hard limit on memory usage
    memswap_limit: 2g     # Limit RAM + swap usage
    mem_reservation: 1g   # Soft memory reservation
    cpus: 2               # Limit to 2 CPU cores
    restart: unless-stopped
    ports:
      - "${SFTP_PORT}:22"  # Map SFTP port from .env file
    volumes:
      - ${SFTP_PATH}:/home/${SFTP_USER}/upload  # Mount local folder
    env_file:
      - .env
    environment:
      - SFTP_USERS=${SFTP_USER}:${SFTP_PASSWORD}:1001
    networks:
      - atmoz-bridge-network

networks:
  atmoz-bridge-network:
    driver: bridgeCode language: PHP (php)

Highlights:

  • Uses environment variables from .env.
  • Memory and CPU limits keep the container lightweight and predictable.
  • Automatically restarts unless stopped manually.
  • Isolated via a custom Docker bridge network.

Step 3. Start the Atmoz SFTP Server

Run the following command in the same directory as your .env and docker-compose.yaml:

docker compose up -d

Check container status:

docker ps

You should see something like:

CONTAINER ID   IMAGE                COMMAND      STATUS         PORTS
abc123456789   atmoz/sftp:latest    "/entry..."  Up 10 seconds  0.0.0.0:22888->22/tcpCode language: JavaScript (javascript)

Step 4. Connect to the SFTP Server

From another machine or SFTP client (e.g., FileZilla, WinSCP, or sftp CLI):

sftp -P 22888 example.user@your.server.ipCode language: CSS (css)

When prompted, enter your SFTP_PASSWORD.

Step 5. Security and Maintenance Tips

  • Use strong passwords, avoid dictionary words.
  • Limit IP access via firewall (ufw or iptables).
  • Implement RSA key, better compare to password.
  • Back up your data , mounted folder (SFTP_PATH) is where files are stored.
  • Update regularly, keep Docker images up to date:
docker compose pull
docker compose up -d

Conclusion

You now have a fully functional, secure-by-default SFTP server running in Docker on Debian 12, with a clean configuration and minimal fuss.

Atmoz SFTP removes the complexity of traditional SFTP setups while still giving you all the benefits of secure file transfers. Perfect for development teams, backup uploads, or controlled client file sharing.

Enabling Redis Password Authentication, ACL, and Master-Slave Security on Debian 12

In a production environment, Redis should never run without proper access control. If you’ve followed the basic Redis master-slave installation guide on Debian 12, it’s time to take the next step: securing your Redis nodes with passwords and user-based authentication (ACL), while ensuring replication remains functional.

This article focuses on three key areas:

  1. Enabling password authentication
  2. Setting up ACL users
  3. Secure master-slave replication using masterauth

1. Enable Basic Password Authentication

The simplest way to secure Redis is via the requirepass directive.

On both master and slave:

Edit /etc/redis/redis.conf:

requirepass YourStrongPassword

Restart Redis:

sudo systemctl restart redis

This protects Redis with a global password. Clients must now authenticate before issuing commands.

2. Advanced: Redis ACL with Users and Permissions (Redis 6+)

Redis ACL allows you to define multiple users with specific command/key access.

Step 1: Enable ACL file

In /etc/redis/redis.conf:

aclfile /etc/redis/users.acl

Step 2: Define a user

Create /etc/redis/users.acl:

user appuser on >SuperSecretPass ~* +@all
Code language: CSS (css)
  • appuser is the username
  • on enables the user
  • > sets the password
  • ~* gives access to all keys
  • +@all enables all command categories

!! You still need to define requirepass if you want to secure replication. Redis master-slave uses the classic password, not ACL user.

Restart Redis:

sudo systemctl restart redis

Test with redis-cli:

redis-cli
> auth appuser SuperSecretPass
> ping
PONG

Limiting Access to Specific Databases

Redis ACL does not support per-user access control to specific logical databases (e.g., SELECT 0, SELECT 1). All users can select any database unless you restrict the select command itself. You can limit users to a specific database by removing their ability to use the select command:

user readonlyuser on >ReadOnlyPass ~* +get +info -select
Code language: JavaScript (javascript)

This user can only operate on the default database (usually DB 0) and cannot switch databases.

Read-Only vs Read-Write Access

You can create users with precise command permissions:

Read-only user:

user readonlyuser on >ReadOnlyPass ~* +get +exists +ttl +info -@write -select
Code language: JavaScript (javascript)

This user can:

  • read keys (get, exists, ttl)
  • check info
  • cannot write or switch DBs

Write-only user:

user writeuser on >WritePass ~* +set +del +incr +@write -@read -select
Code language: JavaScript (javascript)

This user can:

  • write keys (set, del, incr, etc.)
  • cannot read values (get) or switch DBs

Fine-grained access is ideal for separating roles in applications or restricting automated tools.

3. Secure Master-Slave Replication with Password

Redis replicas authenticate to their master using masterauth. This does not support ACL users — it expects the password from requirepass on the master.

On Master (/etc/redis/redis.conf):

requirepass MasterSecret123

On Slave:

replicaof 10.10.10.1 6379
masterauth MasterSecret123
requirepass SlaveSecret456
Code language: CSS (css)

This setup:

  • Allows clients to connect to the slave with SlaveSecret456
  • Allows the slave to connect to the master using MasterSecret123

After restarting both Redis instances, verify on the slave:

redis-cli -a SlaveSecret456
> info replication

Look for:

role:slave
master_link_status:up
Code language: CSS (css)

4. Node.js Example (ACL User)

Install ioredis:

npm install ioredis
const Redis = require('ioredis');

const redis = new Redis({
  host: '127.0.0.1',
  port: 6379,
  username: 'appuser',
  password: 'SuperSecretPass',
});

redis.get('mykey')
  .then(console.log)
  .catch(console.error);
Code language: JavaScript (javascript)

If you’re only using requirepass, omit the username field.

Conclusion

By combining requirepass and Redis ACL, you get both backward compatibility and fine-grained control. For master-slave setups, requirepass is still mandatory for replication, while ACL enhances security for application access.

While Redis supports multiple logical databases (e.g., SELECT 1), it does not isolate them by user. The best practice is to run separate Redis instances or restrict the select command if database isolation is a concern.

With ACLs, you can define highly specific access profiles , read-only, write-only, or command-limited roles giving Redis enterprise-grade access control.