惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Last Watchdog
The Last Watchdog
C
Cyber Attacks, Cyber Crime and Cyber Security
L
LINUX DO - 热门话题
G
GRAHAM CLULEY
S
Schneier on Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
SegmentFault 最新的问题
IT之家
IT之家
阮一峰的网络日志
阮一峰的网络日志
Recorded Future
Recorded Future
I
Intezer
云风的 BLOG
云风的 BLOG
博客园 - Franky
月光博客
月光博客
大猫的无限游戏
大猫的无限游戏
T
Tenable Blog
The Hacker News
The Hacker News
T
The Blog of Author Tim Ferriss
Attack and Defense Labs
Attack and Defense Labs
D
DataBreaches.Net
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
N
News and Events Feed by Topic
有赞技术团队
有赞技术团队
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
N
News and Events Feed by Topic
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
S
Secure Thoughts
The Register - Security
The Register - Security
B
Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
The Cloudflare Blog
Webroot Blog
Webroot Blog
W
WeLiveSecurity
H
Heimdal Security Blog
博客园 - 三生石上(FineUI控件)
V
Vulnerabilities – Threatpost
G
Google Developers Blog
O
OpenAI News
V
V2EX
罗磊的独立博客
博客园_首页
N
News | PayPal Newsroom
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
TaoSecurity Blog
TaoSecurity Blog
Cloudbric
Cloudbric
H
Hacker News: Front Page
博客园 - 叶小钗
T
Tor Project blog
AI
AI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Hardening a Linux Server in the Real World: Firewall, SSH, Fail2Ban, Nginx, Docker, .env Protection, and Bot Forensics
amir · 2026-05-20 · via DEV Community

Every public server becomes part of the internet’s background noise very quickly.

That was not obvious to me in the same way until I started watching production traffic closely. I was not only seeing normal users, crawlers, and health checks. I was also seeing bots probing predictable paths:

/.env
/.env.production
/backup/.env
/wp/.env
/magento/.env
/api/v2/.env
/gateway/.env
/vendor/.env
/storage/.env
/.git/config
/credentials.json
/service-account.json
/__env.js
/actuator/env
/admin/phpinfo.php
/wp-admin/install.php

Enter fullscreen mode Exit fullscreen mode

These were not random requests. They were patterns.

The same categories of paths appeared again and again, usually from new IP addresses, often through CDN ranges, and usually expecting one mistake: a leaked environment file, a forgotten backup, an exposed Git directory, a debug endpoint, a WordPress installer, a Spring actuator route, or a service account JSON file accidentally placed under the web root.

That experience changed how I think about server hardening.

I do not treat security as one tool anymore. I treat it as layers:

  • firewall first
  • SSH exposure reduction
  • key-only authentication
  • non-root users
  • Fail2Ban for behavior-based blocking
  • Nginx deny rules and allow lists
  • Docker isolation
  • process and resource monitoring
  • CDN and WAF in front
  • forensic habits when something looks wrong
  • secret isolation, especially around .env

This article is a practical write-up of how I harden Linux servers based on the real traffic I monitored and handled.

I also built a small Go project for this workflow: WatchTower-Sentinel.

GitHub: https://github.com/amirsefati/WatchTower-Sentinel

It tails Nginx access logs, tracks first-seen client IPs, watches CPU/RAM pressure, inspects suspicious processes, and sends concise Telegram alerts. In my case, it helped me identify real bot behavior and extract request patterns from production-like traffic instead of guessing from theory.


The first rule: assume your server is already being scanned

A fresh public IP is not invisible.

Once a service is reachable from the internet, it will eventually receive probes. Some are harmless crawlers. Some are noisy automated scanners. Some are looking for one specific mistake.

The most common mistake I saw in logs was not a complex exploit. It was a simple file exposure attempt:

GET /.env
GET /.env.production
GET /backup/.env
GET /wp/.env
GET /storage/.env
GET /credentials.json
GET /service-account.json
GET /.git/config

Enter fullscreen mode Exit fullscreen mode

The attacker does not need a zero-day if the application serves secrets as static files.

That is why my hardening starts with boring basics. Boring security is usually the security that actually works.


Step 1: create a normal user and stop working as root

The first thing I do on a server is create a non-root user.

adduser deploy
usermod -aG sudo deploy

Enter fullscreen mode Exit fullscreen mode

Then I copy my SSH key:

mkdir -p /home/deploy/.ssh
nano /home/deploy/.ssh/authorized_keys

chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

Enter fullscreen mode Exit fullscreen mode

After that, I test login in a second terminal before touching root SSH access:

ssh deploy@SERVER_IP

Enter fullscreen mode Exit fullscreen mode

Only after I confirm that the normal user works, I reduce root exposure.

Security is not only about blocking attackers. It is also about avoiding self-inflicted downtime. Never close the old door before testing the new one.


Step 2: harden SSH before enabling aggressive firewall rules

I usually edit:

sudo nano /etc/ssh/sshd_config

Enter fullscreen mode Exit fullscreen mode

These are the important settings:

Port 2222
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
X11Forwarding no
AllowUsers deploy

Enter fullscreen mode Exit fullscreen mode

Then I validate the config:

sudo sshd -t

Enter fullscreen mode Exit fullscreen mode

If validation passes:

sudo systemctl reload ssh

Enter fullscreen mode Exit fullscreen mode

Then I test the new port from another terminal:

ssh -p 2222 deploy@SERVER_IP

Enter fullscreen mode Exit fullscreen mode

Only after that do I close the old SSH port at the firewall level.

Changing the SSH port is not real authentication security by itself. It does not replace keys. But it reduces the volume of automated noise hitting port 22, and that matters because clean logs are easier to investigate.

The real security improvement is:

root login disabled
password login disabled
only specific users allowed
key-based authentication required

Enter fullscreen mode Exit fullscreen mode


Step 3: enable UFW carefully

The easiest way to lock yourself out of a server is to enable a firewall before allowing SSH.

So I always allow the new SSH port first:

sudo ufw default deny incoming
sudo ufw default allow outgoing

sudo ufw allow 2222/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Enter fullscreen mode Exit fullscreen mode

Then enable:

sudo ufw enable
sudo ufw status verbose

Enter fullscreen mode Exit fullscreen mode

If I know my own static IP, I prefer to restrict SSH even more:

sudo ufw delete allow 2222/tcp
sudo ufw allow from YOUR_PUBLIC_IP to any port 2222 proto tcp

Enter fullscreen mode Exit fullscreen mode

This is much better than exposing SSH to the entire internet.

For production servers, I do not like leaving management ports open globally. SSH should be reachable only from trusted IPs, a VPN, a bastion host, or a private network whenever possible.


Step 4: install Fail2Ban for SSH and Nginx behavior

Firewall rules are static. Fail2Ban adds behavior.

Install it:

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

Enter fullscreen mode Exit fullscreen mode

Create a local jail file:

sudo nano /etc/fail2ban/jail.local

Enter fullscreen mode Exit fullscreen mode

For SSH:

[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
findtime = 10m
bantime = 1h
backend = systemd

Enter fullscreen mode Exit fullscreen mode

Then restart:

sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd

Enter fullscreen mode Exit fullscreen mode

Fail2Ban is not only for SSH. It becomes more useful when I add Nginx patterns for real traffic I see.

For example, I saw repeated sensitive-path probes like:

/.env
/.env.production
/.git/config
/credentials.json
/service-account.json
/actuator/env
/admin/phpinfo.php

Enter fullscreen mode Exit fullscreen mode

So I can create a filter:

sudo nano /etc/fail2ban/filter.d/nginx-sensitive-paths.conf

Enter fullscreen mode Exit fullscreen mode

Example pattern:

[Definition]
failregex = ^<HOST> - .* "(GET|POST|HEAD) /(.*)?(\.env|\.git/config|credentials\.json|service-account\.json|__env\.js|actuator/env|phpinfo\.php|wp-admin/install\.php).*" (403|404|444) .*
ignoreregex =

Enter fullscreen mode Exit fullscreen mode

Then add a jail:

[nginx-sensitive-paths]
enabled = true
port = http,https
filter = nginx-sensitive-paths
logpath = /var/log/nginx/access.log
maxretry = 3
findtime = 10m
bantime = 6h

Enter fullscreen mode Exit fullscreen mode

Before trusting a custom filter, I test it:

sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-sensitive-paths.conf

Enter fullscreen mode Exit fullscreen mode

This part is important. A bad regex can either miss real attacks or ban normal users. I prefer starting strict, observing, and then tuning.

My rule is simple: Fail2Ban should block behavior, not curiosity. One weird request may be noise. Repeated sensitive path probing is a pattern.


Step 5: make Nginx reject sensitive files before the application sees them

The application should not be responsible for every bad request.

If a request is obviously targeting secrets, Git metadata, backups, or internal files, Nginx can reject it immediately.

A basic hardening snippet:

# Block hidden files such as .env, .git, .htaccess
location ~ /\.(?!well-known) {
    deny all;
    access_log off;
    log_not_found off;
}

# Block common secret and config file names
location ~* ^/(.*)?(\.env|\.env\..*|credentials\.json|service-account\.json|__env\.js|composer\.(json|lock)|package-lock\.json|yarn\.lock)$ {
    deny all;
    access_log /var/log/nginx/security-access.log;
}

# Block backup/archive/database dump files
location ~* \.(bak|backup|old|orig|save|swp|sql|sqlite|db|tar|gz|zip|7z|rar)$ {
    deny all;
    access_log /var/log/nginx/security-access.log;
}

# Block obvious PHP probing on non-PHP apps
location ~* /(phpinfo\.php|wp-admin/install\.php|xmlrpc\.php)$ {
    return 404;
}

Enter fullscreen mode Exit fullscreen mode

For some routes, I use allow lists.

For example, if an admin panel must only be accessible from office/VPN IPs:

location /admin/ {
    allow YOUR_TRUSTED_IP;
    deny all;

    proxy_pass http://127.0.0.1:3050;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

Enter fullscreen mode Exit fullscreen mode

If the application is behind Cloudflare or another CDN, the real client IP must be restored correctly. Otherwise Nginx and Fail2Ban may only see the CDN proxy IP. That makes banning dangerous because you might ban a proxy instead of the attacker.

In that case, configure the real IP module with trusted CDN ranges and use the correct header, for example:

real_ip_header CF-Connecting-IP;
set_real_ip_from CLOUDFLARE_IP_RANGE;

Enter fullscreen mode Exit fullscreen mode

The exact ranges must be kept updated from the CDN provider.


The .env file deserves its own section

The .env file is one of the most targeted files on the internet.

That is because it often contains exactly what attackers want:

DATABASE_URL
REDIS_URL
JWT_SECRET
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
DIGITALOCEAN_SPACES_KEY
STRIPE_SECRET_KEY
SMTP_PASSWORD
TELEGRAM_BOT_TOKEN
GOOGLE_SERVICE_ACCOUNT_JSON
SENTRY_DSN

Enter fullscreen mode Exit fullscreen mode

A leaked .env can turn a simple HTTP misconfiguration into a full infrastructure incident.

The biggest problem is that .env files are convenient during development, so teams sometimes treat them casually. But in production, .env is not just a config file. It is a secret boundary.

Here is how I handle it.

1. Never place .env under the public web root

This is the most important rule.

Bad idea:

/var/www/app/public/.env
/var/www/html/.env
/usr/share/nginx/html/.env

Enter fullscreen mode Exit fullscreen mode

Better:

/opt/myapp/.env
/etc/myapp/myapp.env
/home/deploy/apps/myapp/.env

Enter fullscreen mode Exit fullscreen mode

The file should exist outside any directory that Nginx can serve as static content.

2. Use strict permissions

For a single application user:

sudo chown deploy:deploy /opt/myapp/.env
sudo chmod 600 /opt/myapp/.env

Enter fullscreen mode Exit fullscreen mode

That means only the owner can read and write it.

For a systemd service, I prefer an environment file:

[Service]
User=deploy
Group=deploy
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js

Enter fullscreen mode Exit fullscreen mode

Then:

sudo chown root:deploy /etc/myapp/myapp.env
sudo chmod 640 /etc/myapp/myapp.env

Enter fullscreen mode Exit fullscreen mode

This allows the service group to read it while preventing random users from reading secrets.

3. Never commit .env

My .gitignore always includes:

.env
.env.*
!.env.example

Enter fullscreen mode Exit fullscreen mode

And .env.example must contain only safe placeholders:

DATABASE_URL=postgres://user:password@localhost:5432/app
JWT_SECRET=change-me
TELEGRAM_BOT_TOKEN=change-me

Enter fullscreen mode Exit fullscreen mode

The example file documents required variables without leaking real values.

4. Do not bake secrets into Docker images

This is a common mistake.

Bad:

ENV DATABASE_URL=postgres://real-secret
COPY .env /app/.env

Enter fullscreen mode Exit fullscreen mode

Better:

services:
  api:
    image: my-api:latest
    env_file:
      - /etc/myapp/myapp.env

Enter fullscreen mode Exit fullscreen mode

Even better in orchestrated environments: use secret managers, platform secrets, Docker secrets, Kubernetes Secrets, or a cloud secret manager.

The image should be portable. Secrets should be injected at runtime.

5. Rotate secrets after exposure

If .env was exposed, removing the file is not enough.

I rotate:

database passwords
API keys
cloud access keys
JWT secrets
SMTP credentials
bot tokens
webhook secrets
object storage keys
third-party service tokens

Enter fullscreen mode Exit fullscreen mode

Then I check logs for suspicious access during the exposure window.

A leaked secret must be treated as used, not just viewed.


Docker: do not casually run everything as root

Docker is not a magic sandbox.

If a process runs as root inside a container, it is still a risk. The level of risk depends on the runtime, capabilities, mounts, namespaces, and daemon configuration, but I avoid unnecessary root containers.

In Dockerfiles, I prefer:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

CMD ["node", "server.js"]

Enter fullscreen mode Exit fullscreen mode

In Compose:

services:
  api:
    build: .
    user: "10001:10001"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp
    ports:
      - "127.0.0.1:3000:3000"

Enter fullscreen mode Exit fullscreen mode

Important habits:

do not mount / unnecessarily
do not mount docker.sock into random containers
drop Linux capabilities when possible
use read-only filesystems where possible
bind services to 127.0.0.1 behind Nginx
avoid privileged: true unless there is a very strong reason

Enter fullscreen mode Exit fullscreen mode

If I need stronger isolation, I look at rootless Docker, user namespaces, seccomp, AppArmor, SELinux, or moving the workload to a more controlled orchestrated environment.

The main idea is simple: if the app is compromised, the attacker should hit walls immediately.


Process limits and resource protection

Security is also availability.

A compromised app, a miner, or a broken process can consume CPU, RAM, file descriptors, or process slots.

For systemd services, I use limits like:

[Service]
User=deploy
Group=deploy
Restart=always
RestartSec=5

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

MemoryMax=500M
CPUQuota=80%
TasksMax=200
LimitNOFILE=65535

Enter fullscreen mode Exit fullscreen mode

For Docker Compose:

services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

Enter fullscreen mode Exit fullscreen mode

Depending on the environment, Compose resource limits may behave differently, so I always verify on the target host.

I also monitor:

top
htop
ps aux --sort=-%cpu | head
ps aux --sort=-%mem | head
systemctl status SERVICE_NAME
journalctl -u SERVICE_NAME -f

Enter fullscreen mode Exit fullscreen mode

And for network activity:

ss -tunap
sudo lsof -i -P -n

Enter fullscreen mode Exit fullscreen mode

This is where WatchTower-Sentinel helped me. Instead of manually checking all the time, I wanted a small sentinel that could detect first-seen IPs, suspicious request paths, high CPU/RAM pressure, and risky process activity, then send compact Telegram alerts.


Detecting miner-like infections and suspicious processes

When I suspect a miner or unwanted process, I do not start by deleting random files.

I first preserve enough information to understand what happened.

My quick triage flow:

uptime
top
ps aux --sort=-%cpu | head -30
ps aux --sort=-%mem | head -30

Enter fullscreen mode Exit fullscreen mode

Then I inspect suspicious processes:

readlink -f /proc/PID/exe
tr '\0' ' ' < /proc/PID/cmdline
ls -la /proc/PID/fd
cat /proc/PID/environ 2>/dev/null | tr '\0' '\n'

Enter fullscreen mode Exit fullscreen mode

Network connections:

ss -tunap
sudo lsof -i -P -n

Enter fullscreen mode Exit fullscreen mode

Recently changed files:

sudo find /tmp /var/tmp /dev/shm -type f -mtime -2 -ls 2>/dev/null
sudo find /etc/systemd /etc/cron* /var/spool/cron -type f -mtime -7 -ls 2>/dev/null

Enter fullscreen mode Exit fullscreen mode

Persistence checks:

crontab -l
sudo ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily
systemctl list-timers
systemctl list-units --type=service --state=running

Enter fullscreen mode Exit fullscreen mode

Logs:

sudo journalctl --since "24 hours ago"
sudo grep -i "failed password" /var/log/auth.log
sudo grep -i "accepted" /var/log/auth.log

Enter fullscreen mode Exit fullscreen mode

Common miner red flags:

high CPU with unknown binary
process running from /tmp, /var/tmp, or /dev/shm
weird random process names
outbound connections to unknown IPs
cron jobs that download shell scripts
systemd services with suspicious ExecStart
unexpected SSH keys added to authorized_keys

Enter fullscreen mode Exit fullscreen mode

When I handled suspicious cases, I treated it like forensics first and cleanup second.

The professional approach is:

identify the process
identify how it started
identify persistence
identify network connections
identify modified files
rotate secrets
patch the entry point
rebuild if trust is lost

Enter fullscreen mode Exit fullscreen mode

If the server is seriously compromised, I do not pretend that deleting one process is enough. I rebuild from a clean image, restore trusted data, rotate credentials, and close the original entry point.

That is the difference between “killing a miner” and actually fixing the incident.


CDN and WAF: why I prefer putting apps behind a protective layer

A CDN is not just for performance.

For public apps, I prefer having a CDN or reverse proxy layer in front because it gives me:

TLS termination
DDoS absorption
bot filtering
WAF managed rules
rate limiting
country/IP rules
header normalization
origin hiding

Enter fullscreen mode Exit fullscreen mode

A WAF is especially useful for common attack classes:

path traversal
SQL injection patterns
XSS probes
known CMS exploit paths
suspicious user agents
automated scanners

Enter fullscreen mode Exit fullscreen mode

But I do not rely on WAF alone.

The origin server must still be hardened.

If the origin IP is exposed and accepts traffic directly, attackers can bypass the CDN. So I restrict the origin to CDN IP ranges where possible, or I put the app behind private networking and only expose the proxy.

A good pattern is:

Internet
  -> CDN / WAF
    -> Nginx
      -> local app on 127.0.0.1
        -> private database/cache

Enter fullscreen mode Exit fullscreen mode

Not:

Internet
  -> Node.js app directly
  -> database accidentally exposed

Enter fullscreen mode Exit fullscreen mode


Nginx security habits that helped me

Here are Nginx patterns I use often.

Hide server tokens

server_tokens off;

Enter fullscreen mode Exit fullscreen mode

Limit request body size

client_max_body_size 10m;

Enter fullscreen mode Exit fullscreen mode

Basic rate limiting

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=api_limit burst=20 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }
}

Enter fullscreen mode Exit fullscreen mode

Security headers

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

Enter fullscreen mode Exit fullscreen mode

For HSTS, I only enable it when I am sure HTTPS is fully correct:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

Enter fullscreen mode Exit fullscreen mode

Deny direct access to internal paths

location ~* ^/(internal|private|backup|storage|vendor)/ {
    deny all;
}

Enter fullscreen mode Exit fullscreen mode

Return 444 for obvious garbage

Sometimes I use:

return 444;

Enter fullscreen mode Exit fullscreen mode

for abusive traffic. It closes the connection without a response. I use it carefully because normal debugging becomes harder if overused.


How WatchTower-Sentinel helped me see patterns

I built WatchTower-Sentinel because I wanted lightweight visibility without deploying a heavy SIEM for every small server.

The idea is simple:

tail Nginx access logs
detect new client IPs
detect request bursts
detect sensitive path scans
watch CPU/RAM pressure
inspect suspicious processes
send compact Telegram alerts

Enter fullscreen mode Exit fullscreen mode

In my Telegram reports, I could see events like:

SENSITIVE_PATH_SCAN
path=/.env
status=404

Enter fullscreen mode Exit fullscreen mode

or:

NEW_IP
path=/credentials.json
status=404

Enter fullscreen mode Exit fullscreen mode

or:

NEW_IP
path=/actuator/env
status=404

Enter fullscreen mode Exit fullscreen mode

This changed the conversation from “maybe bots are scanning us” to “these are the exact paths they are probing.”

That is a big difference.

Once I had the patterns, I could turn them into:

Nginx deny rules
Fail2Ban filters
WAF rules
alert categories
incident review notes

Enter fullscreen mode Exit fullscreen mode

That feedback loop is the most valuable part:

observe -> classify -> block -> monitor -> tune

Enter fullscreen mode Exit fullscreen mode

Security improves when the server teaches you what is happening.


My practical hardening checklist

This is the checklist I like to apply before I trust a server:

Create non-root sudo user
Install SSH key
Disable root SSH login
Disable password SSH login
Move SSH to a non-default port
Allow SSH only from trusted IPs if possible
Enable UFW with default deny incoming
Allow only required ports
Install and configure Fail2Ban
Add custom Nginx filters for sensitive path scans
Block hidden files and secret files in Nginx
Keep .env outside public web roots
Set .env permissions to 600 or 640
Never commit .env
Never bake secrets into Docker images
Run app containers as non-root
Drop Docker capabilities
Avoid privileged containers
Bind internal services to 127.0.0.1
Put production apps behind CDN/WAF
Restrict origin access where possible
Monitor CPU/RAM/process/network behavior
Check cron/systemd persistence during incidents
Rotate secrets after any suspected exposure
Rebuild compromised servers when trust is lost

Enter fullscreen mode Exit fullscreen mode

None of these steps are exotic. But together, they make a huge difference.


Final thoughts

The internet constantly tests basic mistakes.

Most of the traffic I observed was not sophisticated. It was automated, repetitive, and opportunistic.

But that is exactly why basic hardening matters.

If a bot requests /.env and receives 404 or 403, that is good.

If Nginx blocks it before the app sees it, better.

If Fail2Ban detects repeated probes and bans the source, better.

If the origin is behind a CDN/WAF and SSH is restricted, better.

If secrets are outside the web root, permissioned correctly, never committed, and rotated after exposure, much better.

My biggest lesson from running and monitoring real servers is this:

Security is not one big tool. It is a set of small decisions that reduce blast radius.

That is how I approach Linux hardening now.

I start with the boring layers, I watch real traffic, I convert patterns into controls, and I keep improving the system based on what the internet is actually doing to it.

That is also why I built WatchTower-Sentinel.

Not because alerts are cool, but because visibility changes how you defend a server.

Repository:

https://github.com/amirsefati/WatchTower-Sentinel


References