Tools: Hardening a Linux Server in the Real World: Firewall, SSH, Fail2Ban, Nginx, Docker, .env Protection, and Bot Forensics

Tools: Hardening a Linux Server in the Real World: Firewall, SSH, Fail2Ban, Nginx, Docker, .env Protection, and Bot Forensics

The first rule: assume your server is already being scanned

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

Step 2: harden SSH before enabling aggressive firewall rules

Step 3: enable UFW carefully

Step 4: install Fail2Ban for SSH and Nginx behavior

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

The .env file deserves its own section

1. Never place .env under the public web root

2. Use strict permissions

3. Never commit .env

4. Do not bake secrets into Docker images

5. Rotate secrets after exposure

Docker: do not casually run everything as root

Detecting miner-like infections and suspicious processes

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

Nginx security habits that helped me

Hide server tokens

Limit request body size

Basic rate limiting

Security headers

Deny direct access to internal paths

Return 444 for obvious garbage

How WatchTower-Sentinel helped me see patterns

My practical hardening checklist

Final thoughts

References 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: 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: 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. 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: 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. The first thing I do on a server is create a non-root user. Then I copy my SSH key: After that, I test login in a second terminal before touching root SSH access: 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. These are the important settings: Then I validate the config: If validation passes: Then I test the new port from another terminal: 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: 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: If I know my own static IP, I prefer to restrict SSH even more: 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. Firewall rules are static. Fail2Ban adds behavior. Create a local jail file: 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: So I can create a filter: Before trusting a custom filter, I test it: 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. 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: For some routes, I use allow lists. For example, if an admin panel must only be accessible from office/VPN IPs: 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: The exact ranges must be kept updated from the CDN provider. The .env file is one of the most targeted files on the internet. That is because it often contains exactly what attackers want: 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. This is the most important rule. The file should exist outside any directory that Nginx can serve as static content. For a single application user: That means only the owner can read and write it. For a systemd service, I prefer an environment file: This allows the service group to read it while preventing random users from reading secrets. My .gitignore always includes: And .env.example must contain only safe placeholders: The example file documents required variables without leaking real values. This is a common mistake. 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. If .env was exposed, removing the file is not enough. Then I check logs for suspicious access during the exposure window. A leaked secret must be treated as used, not just viewed. 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: 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. 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: Depending on the environment, Compose resource limits may behave differently, so I always verify on the target host. And for network activity: 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. 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: Then I inspect suspicious processes: Recently changed files: Common miner red flags: When I handled suspicious cases, I treated it like forensics first and cleanup second. The professional approach is: 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. 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: A WAF is especially useful for common attack classes: 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. Here are Nginx patterns I use often. For HSTS, I only enable it when I am sure HTTPS is fully correct: for abusive traffic. It closes the connection without a response. I use it carefully because normal debugging becomes harder if overused. I built WatchTower-Sentinel because I wanted lightweight visibility without deploying a heavy SIEM for every small server. In my Telegram reports, I could see events like: 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: That feedback loop is the most valuable part: Security improves when the server teaches you what is happening. This is the checklist I like to apply before I trust a server: None of these steps are exotic. But together, they make a huge difference. 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. https://github.com/amirsefati/WatchTower-Sentinel Templates let you quickly answer FAQs or store snippets for re-use. The path-based deny rules and Fail2Ban filters are solid for known patterns, but the gap is novel paths — a scanner trying /backup-2024/.env.bak or /docker-compose.prod.yml slips right through because it doesn't match your regex. What I've found more effective as a first layer is classifying the requesting IP itself before pattern-matching the path. The vast majority of .env scanners come from identifiable infrastructure — cloud VMs, hosting providers, known scanning services. Checking whether the source IP is a datacenter range or proxy service at the reverse proxy layer catches the scanner regardless of what creative path variation it tries. You can test how different IPs classify at ipasis.com/scan — it's been useful for tuning which source types to flag vs allow through to the application layer. Thanks, great point, I completely agree. That’s exactly why I built a custom monitoring layer connected to Telegram alerts.

Instead of only relying on fixed Nginx deny rules or Fail2Ban regex patterns, I wanted to detect new suspicious paths in real time, observe what bots were actually scanning, and continuously improve the rules based on production traffic.I also agree that IP classification is a strong additional layer, especially for datacenter, proxy, and cloud VM traffic.

For me, the key is layered security: CDN/WAF, IP reputation, Nginx rules, Fail2Ban, log monitoring, and real-time alerting. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

/.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 /.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 /.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 GET /.env GET /.env.production GET /backup/.env GET /wp/.env GET /storage/.env GET /credentials.json GET /service-account.json GET /.git/config GET /.env GET /.env.production GET /backup/.env GET /wp/.env GET /storage/.env GET /credentials.json GET /service-account.json GET /.git/config GET /.env GET /.env.production GET /backup/.env GET /wp/.env GET /storage/.env GET /credentials.json GET /service-account.json GET /.git/config adduser deploy usermod -aG sudo deploy adduser deploy usermod -aG sudo deploy adduser deploy usermod -aG sudo deploy 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 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 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 ssh deploy@SERVER_IP ssh deploy@SERVER_IP ssh deploy@SERVER_IP sudo nano /etc/ssh/sshd_config sudo nano /etc/ssh/sshd_config sudo nano /etc/ssh/sshd_config Port 2222 PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes KbdInteractiveAuthentication no ChallengeResponseAuthentication no X11Forwarding no AllowUsers deploy Port 2222 PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes KbdInteractiveAuthentication no ChallengeResponseAuthentication no X11Forwarding no AllowUsers deploy Port 2222 PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes KbdInteractiveAuthentication no ChallengeResponseAuthentication no X11Forwarding no AllowUsers deploy sudo sshd -t sudo sshd -t sudo sshd -t sudo systemctl reload ssh sudo systemctl reload ssh sudo systemctl reload ssh ssh -p 2222 deploy@SERVER_IP ssh -p 2222 deploy@SERVER_IP ssh -p 2222 deploy@SERVER_IP root login disabled password login disabled only specific users allowed key-based authentication required root login disabled password login disabled only specific users allowed key-based authentication required root login disabled password login disabled only specific users allowed key-based authentication required 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 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 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 sudo ufw enable sudo ufw status verbose sudo ufw enable sudo ufw status verbose sudo ufw enable sudo ufw status verbose sudo ufw delete allow 2222/tcp sudo ufw allow from YOUR_PUBLIC_IP to any port 2222 proto tcp sudo ufw delete allow 2222/tcp sudo ufw allow from YOUR_PUBLIC_IP to any port 2222 proto tcp sudo ufw delete allow 2222/tcp sudo ufw allow from YOUR_PUBLIC_IP to any port 2222 proto tcp sudo apt update sudo apt install fail2ban -y sudo systemctl enable --now fail2ban sudo apt update sudo apt install fail2ban -y sudo systemctl enable --now fail2ban sudo apt update sudo apt install fail2ban -y sudo systemctl enable --now fail2ban sudo nano /etc/fail2ban/jail.local sudo nano /etc/fail2ban/jail.local sudo nano /etc/fail2ban/jail.local [sshd] enabled = true port = 2222 filter = sshd logpath = /var/log/auth.log maxretry = 3 findtime = 10m bantime = 1h backend = systemd [sshd] enabled = true port = 2222 filter = sshd logpath = /var/log/auth.log maxretry = 3 findtime = 10m bantime = 1h backend = systemd [sshd] enabled = true port = 2222 filter = sshd logpath = /var/log/auth.log maxretry = 3 findtime = 10m bantime = 1h backend = systemd sudo systemctl restart fail2ban sudo fail2ban-client status sudo fail2ban-client status sshd sudo systemctl restart fail2ban sudo fail2ban-client status sudo fail2ban-client status sshd sudo systemctl restart fail2ban sudo fail2ban-client status sudo fail2ban-client status sshd /.env /.env.production /.git/config /credentials.json /service-account.json /actuator/env /admin/phpinfo.php /.env /.env.production /.git/config /credentials.json /service-account.json /actuator/env /admin/phpinfo.php /.env /.env.production /.git/config /credentials.json /service-account.json /actuator/env /admin/phpinfo.php sudo nano /etc/fail2ban/filter.d/nginx-sensitive-paths.conf sudo nano /etc/fail2ban/filter.d/nginx-sensitive-paths.conf sudo nano /etc/fail2ban/filter.d/nginx-sensitive-paths.conf [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 = [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 = [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 = [nginx-sensitive-paths] enabled = true port = http,https filter = nginx-sensitive-paths logpath = /var/log/nginx/access.log maxretry = 3 findtime = 10m bantime = 6h [nginx-sensitive-paths] enabled = true port = http,https filter = nginx-sensitive-paths logpath = /var/log/nginx/access.log maxretry = 3 findtime = 10m bantime = 6h [nginx-sensitive-paths] enabled = true port = http,https filter = nginx-sensitive-paths logpath = /var/log/nginx/access.log maxretry = 3 findtime = 10m bantime = 6h sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-sensitive-paths.conf sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-sensitive-paths.conf sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-sensitive-paths.conf # 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; } # 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; } # 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; } 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; } 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; } 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; } real_ip_header CF-Connecting-IP; set_real_ip_from CLOUDFLARE_IP_RANGE; real_ip_header CF-Connecting-IP; set_real_ip_from CLOUDFLARE_IP_RANGE; real_ip_header CF-Connecting-IP; set_real_ip_from CLOUDFLARE_IP_RANGE; 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 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 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 /var/www/app/public/.env /var/www/html/.env /usr/share/nginx/html/.env /var/www/app/public/.env /var/www/html/.env /usr/share/nginx/html/.env /var/www/app/public/.env /var/www/html/.env /usr/share/nginx/html/.env /opt/myapp/.env /etc/myapp/myapp.env /home/deploy/apps/myapp/.env /opt/myapp/.env /etc/myapp/myapp.env /home/deploy/apps/myapp/.env /opt/myapp/.env /etc/myapp/myapp.env /home/deploy/apps/myapp/.env sudo chown deploy:deploy /opt/myapp/.env sudo chmod 600 /opt/myapp/.env sudo chown deploy:deploy /opt/myapp/.env sudo chmod 600 /opt/myapp/.env sudo chown deploy:deploy /opt/myapp/.env sudo chmod 600 /opt/myapp/.env [Service] User=deploy Group=deploy EnvironmentFile=/etc/myapp/myapp.env ExecStart=/usr/bin/node /opt/myapp/server.js [Service] User=deploy Group=deploy EnvironmentFile=/etc/myapp/myapp.env ExecStart=/usr/bin/node /opt/myapp/server.js [Service] User=deploy Group=deploy EnvironmentFile=/etc/myapp/myapp.env ExecStart=/usr/bin/node /opt/myapp/server.js sudo chown root:deploy /etc/myapp/myapp.env sudo chmod 640 /etc/myapp/myapp.env sudo chown root:deploy /etc/myapp/myapp.env sudo chmod 640 /etc/myapp/myapp.env sudo chown root:deploy /etc/myapp/myapp.env sudo chmod 640 /etc/myapp/myapp.env .env .env.* !.env.example .env .env.* !.env.example .env .env.* !.env.example DATABASE_URL=postgres://user:password@localhost:5432/app JWT_SECRET=change-me TELEGRAM_BOT_TOKEN=change-me DATABASE_URL=postgres://user:password@localhost:5432/app JWT_SECRET=change-me TELEGRAM_BOT_TOKEN=change-me DATABASE_URL=postgres://user:password@localhost:5432/app JWT_SECRET=change-me TELEGRAM_BOT_TOKEN=change-me ENV DATABASE_URL=postgres://real-secret COPY .env /app/.env ENV DATABASE_URL=postgres://real-secret COPY .env /app/.env ENV DATABASE_URL=postgres://real-secret COPY .env /app/.env services: api: image: my-api:latest env_file: - /etc/myapp/myapp.env services: api: image: my-api:latest env_file: - /etc/myapp/myapp.env services: api: image: my-api:latest env_file: - /etc/myapp/myapp.env database passwords API keys cloud access keys JWT secrets SMTP credentials bot tokens webhook secrets object storage keys third-party service tokens database passwords API keys cloud access keys JWT secrets SMTP credentials bot tokens webhook secrets object storage keys third-party service tokens database passwords API keys cloud access keys JWT secrets SMTP credentials bot tokens webhook secrets object storage keys third-party service tokens 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"] 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"] 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"] 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" 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" 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" 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 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 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 [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 [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 [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 services: api: deploy: resources: limits: cpus: "1.0" memory: 512M services: api: deploy: resources: limits: cpus: "1.0" memory: 512M services: api: deploy: resources: limits: cpus: "1.0" memory: 512M top htop ps aux --sort=-%cpu | head ps aux --sort=-%mem | head systemctl status SERVICE_NAME journalctl -u SERVICE_NAME -f top htop ps aux --sort=-%cpu | head ps aux --sort=-%mem | head systemctl status SERVICE_NAME journalctl -u SERVICE_NAME -f top htop ps aux --sort=-%cpu | head ps aux --sort=-%mem | head systemctl status SERVICE_NAME journalctl -u SERVICE_NAME -f ss -tunap sudo lsof -i -P -n ss -tunap sudo lsof -i -P -n ss -tunap sudo lsof -i -P -n uptime top ps aux --sort=-%cpu | head -30 ps aux --sort=-%mem | head -30 uptime top ps aux --sort=-%cpu | head -30 ps aux --sort=-%mem | head -30 uptime top ps aux --sort=-%cpu | head -30 ps aux --sort=-%mem | head -30 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' 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' 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' ss -tunap sudo lsof -i -P -n ss -tunap sudo lsof -i -P -n ss -tunap sudo lsof -i -P -n 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 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 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 crontab -l sudo ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily systemctl list-timers systemctl list-units --type=service --state=running crontab -l sudo ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily systemctl list-timers systemctl list-units --type=service --state=running crontab -l sudo ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily systemctl list-timers systemctl list-units --type=service --state=running sudo journalctl --since "24 hours ago" sudo grep -i "failed password" /var/log/auth.log sudo grep -i "accepted" /var/log/auth.log sudo journalctl --since "24 hours ago" sudo grep -i "failed password" /var/log/auth.log sudo grep -i "accepted" /var/log/auth.log sudo journalctl --since "24 hours ago" sudo grep -i "failed password" /var/log/auth.log sudo grep -i "accepted" /var/log/auth.log 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 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 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 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 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 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 TLS termination DDoS absorption bot filtering WAF managed rules rate limiting country/IP rules header normalization origin hiding TLS termination DDoS absorption bot filtering WAF managed rules rate limiting country/IP rules header normalization origin hiding TLS termination DDoS absorption bot filtering WAF managed rules rate limiting country/IP rules header normalization origin hiding path traversal SQL injection patterns XSS probes known CMS exploit paths suspicious user agents automated scanners path traversal SQL injection patterns XSS probes known CMS exploit paths suspicious user agents automated scanners path traversal SQL injection patterns XSS probes known CMS exploit paths suspicious user agents automated scanners Internet -> CDN / WAF -> Nginx -> local app on 127.0.0.1 -> private database/cache Internet -> CDN / WAF -> Nginx -> local app on 127.0.0.1 -> private database/cache Internet -> CDN / WAF -> Nginx -> local app on 127.0.0.1 -> private database/cache Internet -> Node.js app directly -> database accidentally exposed Internet -> Node.js app directly -> database accidentally exposed Internet -> Node.js app directly -> database accidentally exposed server_tokens off; server_tokens off; server_tokens off; client_max_body_size 10m; client_max_body_size 10m; client_max_body_size 10m; 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; } } 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; } } 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; } } 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; 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; 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; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; location ~* ^/(internal|private|backup|storage|vendor)/ { deny all; } location ~* ^/(internal|private|backup|storage|vendor)/ { deny all; } location ~* ^/(internal|private|backup|storage|vendor)/ { deny all; } return 444; return 444; return 444; 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 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 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 SENSITIVE_PATH_SCAN path=/.env status=404 SENSITIVE_PATH_SCAN path=/.env status=404 SENSITIVE_PATH_SCAN path=/.env status=404 NEW_IP path=/credentials.json status=404 NEW_IP path=/credentials.json status=404 NEW_IP path=/credentials.json status=404 NEW_IP path=/actuator/env status=404 NEW_IP path=/actuator/env status=404 NEW_IP path=/actuator/env status=404 Nginx deny rules Fail2Ban filters WAF rules alert categories incident review notes Nginx deny rules Fail2Ban filters WAF rules alert categories incident review notes Nginx deny rules Fail2Ban filters WAF rules alert categories incident review notes observe -> classify -> block -> monitor -> tune observe -> classify -> block -> monitor -> tune observe -> classify -> block -> monitor -> tune 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 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 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 - 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 - Ubuntu UFW documentation: https://ubuntu.com/server/docs/how-to/security/firewalls/ - Nginx access module: https://nginx.org/en/docs/http/ngx_http_access_module.html - Nginx documentation: https://nginx.org/en/docs/ - Fail2Ban filters documentation: https://fail2ban.readthedocs.io/en/latest/filters.html - Docker rootless mode: https://docs.docker.com/engine/security/rootless/ - Joined Mar 10, 2026 - Location Yerevan - Education Master in Computer science - Work Software engineer, System Architect - Joined Apr 26, 2019