Blog

  • Securing SSH and Apache on Raspberry Pi with Fail2Ban and nftables

    Introduction

    This guide explains how to install and configure Fail2Ban on a Raspberry Pi to protect:

    • SSH (sshd) from brute-force attacks
    • Apache web server from malicious requests and bots

    The setup uses nftables (modern firewall backend) instead of legacy iptables.


    Prerequisites

    • Raspberry Pi running Raspberry Pi OS (Debian-based)
    • Root or sudo access
    • Apache installed and logging to:
      • /var/log/apache2/access.log

    1. Install Required Packages

    sudo apt update
    sudo apt install fail2ban nftables -y

    Enable and start nftables:

    sudo systemctl enable nftables
    sudo systemctl start nftables

    2. Configure nftables (Base Firewall)

    Edit the configuration:

    sudo nano /etc/nftables.conf

    Example minimal ruleset:

    table inet filter {
        chain input {
            type filter hook input priority 0;
            policy drop;
    
            iif lo accept
            ct state established,related accept
    
            tcp dport 22 accept
            ip protocol icmp accept
    
            counter drop
        }
    }

    Apply the rules:

    sudo nft -f /etc/nftables.conf

    3. Configure Fail2Ban

    Create a local configuration file:

    sudo nano /etc/fail2ban/jail.local

    Global Settings

    [DEFAULT]
    banaction = nftables-multiport
    banaction_allports = nftables-allports
    
    findtime = 10m
    bantime = 1h
    maxretry = 5
    
    backend = systemd
    
    ignoreip = 127.0.0.1/8 ::1

    4. Protect SSH (sshd)

    Add the following:

    [sshd]
    enabled = true
    port = ssh
    filter = sshd
    backend = systemd
    
    maxretry = 5
    findtime = 10m
    bantime = 1h

    5. Protect Apache

    Add protection for multiple access logs:

    # Authentication failures
    [apache-auth]
    enabled  = true
    port     = http,https
    filter   = apache-auth
    logpath  = /var/log/apache2/access.log
    maxretry = 5
    findtime = 10m
    bantime  = 1h
    
    # Bad bots and scanners
    [apache-badbots]
    enabled  = true
    port     = http,https
    filter   = apache-badbots
    logpath  = /var/log/apache2/access.log
    maxretry = 2
    findtime = 10m
    bantime  = 6h
    
    # Script probing (phpmyadmin, etc.)
    [apache-noscript]
    enabled  = true
    port     = http,https
    filter   = apache-noscript
    logpath  = /var/log/apache2/access.log
    maxretry = 3
    findtime = 10m
    bantime  = 6h
    
    # Exploit attempts
    [apache-overflows]
    enabled  = true
    port     = http,https
    filter   = apache-overflows
    logpath  = /var/log/apache2/access.log
    maxretry = 2
    findtime = 10m
    bantime  = 12h

    6. Start and Enable Fail2Ban

    sudo systemctl enable fail2ban
    sudo systemctl start fail2ban

    7. Verify Configuration

    Check active jails:

    sudo fail2ban-client status

    Example output should include:

    • sshd
    • apache-auth
    • apache-badbots
    • apache-noscript
    • apache-overflows

    Check a specific jail:

    sudo fail2ban-client status sshd

    8. Monitor Logs

    Real-time monitoring:

    sudo tail -f /var/log/fail2ban.log

    Check SSH logs:

    sudo journalctl -u ssh

    9. Testing

    Simulate failed SSH logins:

    ssh invaliduser@localhost

    After multiple failures, verify the ban:

    sudo fail2ban-client status sshd

    10. Important Notes

    • Ensure Apache logs use a standard format (combined log format recommended)
    • Avoid mixing iptables with nftables
    • Always whitelist your own IP using ignoreip
    • Custom log formats may require custom Fail2Ban filters

    11. Optional Hardening

    Edit SSH configuration:

    sudo nano /etc/ssh/sshd_config

    Recommended settings:

    PermitRootLogin no
    PasswordAuthentication no

    Restart SSH:

    sudo systemctl restart ssh

    Conclusion

    With this setup:

    • SSH brute-force attacks are automatically blocked
    • Apache scanners and malicious bots are banned
    • Firewall rules are handled efficiently using nftables

    This provides a solid baseline security layer for any Raspberry Pi exposed to the internet.

  • πŸš€ WordPress Migration Guide: Server to Raspberry Pi

    Developed by Gemini

    Phase 1: Packing Up (Old Server)

    1. Zip the Files Navigate to your WordPress root and compress all files:

    Bash

    tar -czvf site_backup.tar.gz .
    

    2. Export the Database Create a snapshot of your current database:

    Bash

    mysqldump -u [old_db_user] -p [old_db_name] > database_backup.sql
    

    Transfer both site_backup.tar.gz and database_backup.sql to your Raspberry Pi.


    Phase 2: Preparing the New Home (Raspberry Pi)

    3. Create the Database and User Log into MariaDB on your Pi:

    Bash

    sudo mariadb -u root -p
    

    Run these commands (replace placeholders with your choices):

    SQL

    CREATE DATABASE dummy_db;
    CREATE USER 'dummy_user'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD';
    GRANT ALL PRIVILEGES ON dummy_db.* TO 'dummy_user'@'localhost';
    FLUSH PRIVILEGES;
    EXIT;
    

    4. Deploy Files & Adjust Permissions Create the folder structure and extract your backup:

    Bash

    sudo mkdir -p /var/www/yourdomain.com/public_html
    sudo tar -xzvf site_backup.tar.gz -C /var/www/yourdomain.com/public_html
    

    Now, fix the ownership so the Apache web server (www-data) can manage the files:

    Bash

    sudo chown -R www-data:www-data /var/www/yourdomain.com
    sudo find /var/www/yourdomain.com -type d -exec chmod 755 {} \;
    sudo find /var/www/yourdomain.com -type f -exec chmod 644 {} \;
    

    5. Update wp-config.php Edit the configuration to match your new database credentials:

    Bash

    sudo nano /var/www/yourdomain.com/public_html/wp-config.php
    

    Update DB_NAME (dummy_db), DB_USER (dummy_user), and DB_PASSWORD.


    Phase 3: Web Server & Security

    6. Apache Configuration Create a new Virtual Host file:

    Bash

    sudo nano /etc/apache2/sites-available/yourdomain.conf
    

    Paste this configuration:

    Apache

    <VirtualHost *:80>
        ServerName yourdomain.com
        DocumentRoot /var/www/yourdomain.com/public_html
    
        <Directory /var/www/yourdomain.com/public_html>
            AllowOverride All
            Require all granted
        </Directory>
    
        ErrorLog ${APACHE_LOG_DIR}/mysite_error.log
        CustomLog ${APACHE_LOG_DIR}/mysite_access.log combined
    </VirtualHost>
    

    Enable the site and reload Apache:

    Bash

    sudo a2ensite yourdomain.conf
    sudo systemctl reload apache2
    

    7. Secure with Let’s Encrypt Request your SSL certificate:

    Bash

    sudo apt install certbot python3-certbot-apache -y
    sudo certbot --apache -d yourdomain.com
    

    Phase 4: Path & URL Synchronization

    8. Import the Database Import your SQL file into the new database:

    Bash

    mysql -u dummy_user -p dummy_db < database_backup.sql
    

    9. Update File Paths and URLs This step is critical. WordPress stores absolute file paths (e.g., /home/olduser/public_html) and URLs in the database. If these aren’t updated to your Pi’s path (/var/www/yourdomain.com/public_html), your site will crash.

    Using WP-CLI (The most reliable way):

    Bash

    cd /var/www/yourdomain.com/public_html
    
    # Update the Domain URL
    wp search-replace 'https://old-domain.com' 'https://yourdomain.com' --allow-root
    
    # Update the System File Path
    # Replace '/old/path/to/site' with the path from your old server
    wp search-replace '/old/path/to/site' '/var/www/yourdomain.com/public_html' --allow-root
  • πŸš€ Complete Demo – Bidirectional PostgreSQL Replication on Raspberry Pi


    🧱 Architecture

    NodeContainerHost PortData Path
    node1postgres-node15432/app/pg1
    node2postgres-node25433/app/pg2

    Logical active-active:

    node1  <---->  node2

    1️⃣ Install Docker (Official – Mandatory)

    Remove old versions:

    sudo apt remove docker docker-engine docker.io containerd runc -y

    Install official Docker:

    curl -fsSL https://get.docker.com | sudo sh

    Add your user:

    sudo usermod -aG docker $USER
    newgrp docker

    Validate:

    docker version
    docker compose version

    You must use:

    docker compose

    2️⃣ Prepare Persistent Storage

    sudo mkdir -p /app/pg1
    sudo mkdir -p /app/pg2
    sudo chown -R 999:999 /app

    Why 999?
    Inside the container, postgres runs as UID 999.


    3️⃣ Build Custom PostgreSQL Image with pglogical

    Create working directory:

    mkdir ~/pglogical-demo
    cd ~/pglogical-demo

    Create Dockerfile

    nano Dockerfile

    Paste:

    FROM postgres:15LABEL description="PostgreSQL 15 with pglogical"ENV DEBIAN_FRONTEND=noninteractiveRUN apt-get update && \
    apt-get install -y --no-install-recommends \
    postgresql-15-pglogical \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*CMD ["postgres"]

    Why this matters

    • Version must match (15 ↔ 15)
    • Installs pglogical binaries
    • Keeps image small
    • Works on ARM (Raspberry Pi 64-bit)

    Build image

    docker build -t postgres-pglogical:15 .

    Verify extension exists:

    docker run --rm postgres-pglogical:15 \
    ls /usr/lib/postgresql/15/lib | grep pglogical

    You must see:

    pglogical.so

    4️⃣ Create docker-compose.yml

    nano docker-compose.yml

    Paste:

    version: '3.8'services:
    node1:
    image: postgres-pglogical:15
    container_name: postgres-node1
    environment:
    POSTGRES_USER: repl
    POSTGRES_PASSWORD: replpass
    POSTGRES_DB: demo
    ports:
    - "5432:5432"
    volumes:
    - /app/pg1:/var/lib/postgresql/data
    command: >
    postgres
    -c wal_level=logical
    -c max_wal_senders=10
    -c max_replication_slots=10
    -c shared_preload_libraries=pglogical
    networks:
    - pgnet node2:
    image: postgres-pglogical:15
    container_name: postgres-node2
    environment:
    POSTGRES_USER: repl
    POSTGRES_PASSWORD: replpass
    POSTGRES_DB: demo
    ports:
    - "5433:5432"
    volumes:
    - /app/pg2:/var/lib/postgresql/data
    command: >
    postgres
    -c wal_level=logical
    -c max_wal_senders=10
    -c max_replication_slots=10
    -c shared_preload_libraries=pglogical
    networks:
    - pgnetnetworks:
    pgnet:
    driver: bridge

    5️⃣ Start Environment

    docker compose up -d

    Verify:

    docker ps

    6️⃣ Enable pglogical Extension

    On both nodes:

    docker exec -it postgres-node1 psql -U repl -d demo
    CREATE EXTENSION pglogical;

    Repeat on node2.


    7️⃣ Configure Logical Nodes

    Node1

    SELECT pglogical.create_node(
    node_name := 'node1',
    dsn := 'host=node1 port=5432 dbname=demo user=repl password=replpass'
    );

    Node2

    SELECT pglogical.create_node(
    node_name := 'node2',
    dsn := 'host=node2 port=5432 dbname=demo user=repl password=replpass'
    );

    8️⃣ Create Subscriptions (Bidirectional)

    Node1:

    SELECT pglogical.create_subscription(
    subscription_name := 'sub_node2',
    provider_dsn := 'host=node2 port=5432 dbname=demo user=repl password=replpass'
    );

    Node2:

    SELECT pglogical.create_subscription(
    subscription_name := 'sub_node1',
    provider_dsn := 'host=node1 port=5432 dbname=demo user=repl password=replpass'
    );

    9️⃣ Create Replicated Table

    On node1:

    CREATE TABLE test_sync (
    id SERIAL PRIMARY KEY,
    name TEXT,
    created_at TIMESTAMP DEFAULT now()
    );SELECT pglogical.replication_set_add_table(
    set_name := 'default',
    relation := 'test_sync'
    );

    Repeat replication_set_add_table on node2.


    πŸ”Ÿ Prevent Primary Key Conflicts

    Node1:

    ALTER SEQUENCE test_sync_id_seq RESTART WITH 1 INCREMENT BY 2;

    Node2:

    ALTER SEQUENCE test_sync_id_seq RESTART WITH 2 INCREMENT BY 2;

    Now:

    • node1 generates odd IDs
    • node2 generates even IDs

    πŸ” Replication Validation Commands

    Check subscription status:

    SELECT * FROM pglogical.show_subscription_status();

    Check replication slots:

    SELECT slot_name, active FROM pg_replication_slots;

    Check streaming state:

    SELECT client_addr, state FROM pg_stat_replication;

    If:

    • status = replicating
    • active = true
    • state = streaming

    Replication is healthy.


    βœ… Prove Replication (Insert Test)

    Insert on node1:

    INSERT INTO test_sync (name) VALUES ('insert_from_node1');

    Check on node2:

    SELECT * FROM test_sync;

    Insert on node2:

    INSERT INTO test_sync (name) VALUES ('insert_from_node2');

    Check on node1:

    SELECT * FROM test_sync;

    If both rows exist on both nodes β†’ confirmed.


    πŸ” Full Environment Rebuild

    Stop containers:

    docker compose down

    Delete data:

    sudo rm -rf /app/pg1/*
    sudo rm -rf /app/pg2/*

    Start fresh:

    docker compose up -d

    Reconfigure pglogical.


    🧨 Disaster Scenario – Delete Node2 and Restore from Node1

    Stop node2

    docker stop postgres-node2

    Delete node2 data

    sudo rm -rf /app/pg2/*

    Backup node1

    docker exec postgres-node1 pg_dump -U repl demo > backup.sql

    Start node2

    docker start postgres-node2

    Restore

    cat backup.sql | docker exec -i postgres-node2 psql -U repl -d demo

    Recreate subscription if needed

    SELECT pglogical.create_subscription(
    subscription_name := 'sub_node1',
    provider_dsn := 'host=node1 port=5432 dbname=demo user=repl password=replpass'
    );

    Validate again.



    🧹 Cleanup: Remove Containers, Images and Network

    After testing replication between node1 and node2, you may want to completely remove all resources created during this lab.

    We’ll remove:

    • Containers
    • Volumes (if used)
    • Custom Docker network
    • PostgreSQL images

    Stop and Remove Containers

    If you created containers manually:

    docker rm -f postgres-node1 postgres-node2

    Verify:

    docker ps -a

    They should no longer appear.



    Remove Custom Docker Network

    Since you created:

    networks:
    pglogical-demo_pgnet:
    driver: bridge

    Remove it:

    docker network rm pglogical-demo_pgnet

    Verify:

    docker network ls

    Remove PostgreSQL Images

    If you pulled the official image from Docker Hub (for example postgres:15), remove it:

    docker images
    docker rmi postgres-pglogical:15



    πŸ”Ž Verify Everything Is Clean

    Run:

    docker ps -a
    docker volume ls
    docker network ls
    docker images

    You should see no leftover lab resources.

  • How to Manage Cron Jobs in Linux (Check, List, Create and Automate Log Cleanup)


    Introduction

    Task automation is a fundamental responsibility of any Linux system administrator. One of the most powerful and widely used tools for scheduling tasks is cron.

    In this guide, you will learn:

    • How to verify if cron is installed and running
    • How to list existing cron jobs
    • How to create a Bash script
    • How to set proper permissions
    • How to schedule it safely using cron

    We’ll use a real-world example: deleting log files older than 120 days from /var/log.


    1️⃣ Check if Cron is Installed

    Most Linux distributions use:

    • cron (Debian/Ubuntu)
    • cronie (RHEL/CentOS/Alma)

    Check if cron exists:

    which cron

    Or:

    rpm -qa | grep cron

    On Debian-based systems:

    dpkg -l | grep cron

    2️⃣ Check if Cron Service is Running

    On modern systems using systemd:

    systemctl status cron

    Or on RHEL-based systems:

    systemctl status crond

    You should see:

    Active: active (running)

    If not running:

    sudo systemctl start cron
    sudo systemctl enable cron

    Cron is now persistent across reboots.


    3️⃣ List Existing Cron Jobs

    List Current User Cron Jobs

    crontab -l

    List Another User’s Cron Jobs

    sudo crontab -u username -l

    System-wide Cron Locations

    Check:

    cat /etc/crontab
    ls -la /etc/cron.d/
    ls -la /etc/cron.daily/

    These directories are commonly used for system-level scheduled tasks.


    4️⃣ Create a Bash Script to Delete Old Log Files

    Now we create a practical script.

    Create the file:

    sudo nano /usr/local/bin/cleanup_logs.sh

    Add the following content:

    #!/bin/bashLOG_DIR="/var/log"
    RETENTION_DAYS=120echo "Starting log cleanup: $(date)"find "$LOG_DIR" -type f -mtime +$RETENTION_DAYS -exec rm -f {} \;echo "Cleanup finished: $(date)"

    Save and exit.


    5️⃣ Set Proper Permissions (Critical Step)

    This is where many people fail.

    Check current permissions:

    ls -l /usr/local/bin/cleanup_logs.sh

    Make it executable:

    sudo chmod 750 /usr/local/bin/cleanup_logs.sh

    Set ownership to root:

    sudo chown root:root /usr/local/bin/cleanup_logs.sh

    Now verify:

    ls -l /usr/local/bin/cleanup_logs.sh

    You should see something like:

    -rwxr-x--- 1 root root

    Why this matters:

    • Only root can modify it
    • Only root and group can execute it
    • Prevents privilege escalation risks

    6️⃣ Test the Script Manually

    Always test before scheduling:

    sudo /usr/local/bin/cleanup_logs.sh

    If no errors appear, proceed.


    7️⃣ Add Script to Cron

    Edit root crontab:

    sudo crontab -e

    Add this line to run daily at 02:30 AM:

    30 2 * * * /usr/local/bin/cleanup_logs.sh >> /var/log/cleanup_logs.log 2>&1

    Explanation:

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€ minute (30)
    β”‚ β”Œβ”€β”€β”€β”€β”€β”€ hour (2)
    β”‚ β”‚ β”Œβ”€β”€β”€β”€ day of month (*)
    β”‚ β”‚ β”‚ β”Œβ”€β”€ month (*)
    β”‚ β”‚ β”‚ β”‚ β”Œ day of week (*)
    β”‚ β”‚ β”‚ β”‚ β”‚
    30 2 * * * command

    This will:

    • Run daily at 02:30
    • Redirect output to a log file
    • Capture errors (2>&1)

    8️⃣ Verify Cron is Executing

    Check logs:

    On Debian/Ubuntu:

    grep CRON /var/log/syslog

    On RHEL-based systems:

    grep CROND /var/log/cron

    ⚠️ Important Production Advice

    Blind deletion in /var/log can be dangerous.

    Safer approach:

    find "$LOG_DIR" -type f -name "*.log" -mtime +$RETENTION_DAYS -exec rm -f {} \;

    Even better:

    • Use logrotate for managed log rotation
    • Never delete application logs without validation
    • Monitor disk usage with alerts

    Cron is powerful β€” and dangerous if misused.


    What You Learned

    • How to verify cron installation
    • How to check service status
    • How to list user and system cron jobs
    • How to write and secure a Bash script
    • How to schedule tasks safely
    • How to log cron output

    Why This Matters

    Automating maintenance tasks:

    • Prevents disk full scenarios
    • Reduces manual operations
    • Improves system reliability
    • Demonstrates real sysadmin maturity

    Automation is not optional in production environments.

  • How to Connect Two Docker Containers Over a Custom Bridge Network (Step-by-Step Guide)

    πŸ“ Introduction

    Modern applications rarely run as a single service. In containerized environments, networking between services is fundamental.

    In this guide, we’ll create two containers:

    • A web server (Nginx)
    • A test client (Alpine Linux)

    We’ll connect them using a custom Docker bridge network and verify communication between them.

    This is the foundation of microservices networking.


    πŸ”Ή Prerequisites

    • Linux machine (or VM)
    • Docker installed
    • Basic CLI knowledge

    Check Docker:

    docker --version

    If not installed, follow the official guide from Docker.


    Step 1 – Create a Custom Bridge Network

    By default, Docker creates a bridge network, but best practice is to create your own isolated network.

    docker network create my_custom_network

    Verify:

    docker network ls

    Inspect it:

    docker network inspect my_custom_network

    You’ll see:

    • Subnet
    • Gateway
    • Driver type (bridge)

    Step 2 – Run the First Container (Nginx Web Server)

    We’ll use the official image from Docker Hub:

    docker run -d \
    --name webserver \
    --network my_custom_network \
    nginx

    Check it’s running:

    docker ps

    Step 3 – Run the Second Container (Alpine Client)

    Now launch a lightweight Alpine container:

    docker run -it \
    --name client \
    --network my_custom_network \
    alpine sh

    Inside the container, install curl:

    apk add curl

    Step 4 – Test Connectivity Using Container Name

    This is the key concept.

    Docker provides internal DNS resolution inside user-defined bridge networks.

    From inside the Alpine container:

    curl http://webserver

    If everything is correct, you’ll see the default Nginx welcome page.

    Why does this work?

    Because Docker automatically registers container names in its internal DNS when using a custom bridge network.


    Step 5 – Verify Network Isolation

    From your host machine:

    curl http://localhost

    This will NOT work unless you publish ports.

    That’s because we did not expose any ports externally.

    To expose Nginx:

    docker rm -f webserverdocker run -d \
    --name webserver \
    --network my_custom_network \
    -p 8080:80 \
    nginx

    Now:

    curl http://localhost:8080

    πŸ” What You Just Learned

    • How Docker bridge networking works
    • Internal DNS resolution between containers
    • Service isolation principles
    • Port publishing to host
    • Basic microservice communication model

    🧱 Why This Matters

    This exact pattern is used in:

    • Kubernetes pod networking
    • Microservices architectures
    • API-to-database communication
    • Service mesh environments

    Understanding this at Docker level makes advanced orchestration tools easier to grasp later.