srmdn.

Back

Attacked Every 23 Seconds. Why I'm Not Worried.Blur image

When I checked my server logs last week, I found over 23,000 failed SSH login attempts in seven days. That works out to roughly one attempt every 26 seconds, around the clock.

My first reaction was panic. My second was: this is completely normal.

Any server with a public IP gets this. It is not a targeted attack. It is automated bots sweeping the entire internet, trying default credentials on every IP they find. They are looking for the one server where someone left the root password as admin123, or where SSH still accepts passwords at all. They do not know whose server this is. They do not care.

What stops them is not magic. It is several layers of boring configuration.


Layer 1: SSH Hardening#

SSH is the most common attack vector on a public VPS. The bots know this. So the first job is making your SSH as uninteresting as possible.

Disable password authentication. This is the single most important change. With PasswordAuthentication no, a bot can guess the right username and it still does not matter. They cannot get in without your private key. Password brute-force stops being a threat entirely.

# /etc/ssh/sshd_config
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin prohibit-password
plaintext

Move off port 22. Port 22 is the first port every SSH scanner checks. Moving to a non-standard port will not stop a determined attacker, but it eliminates most background noise and reduces log spam significantly.

Tighten the other knobs. A few more settings worth setting explicitly:

MaxAuthTries 3        # disconnect after 3 failed attempts per connection
LoginGraceTime 30     # 30 seconds to authenticate, not the default 2 minutes
X11Forwarding no      # no reason to have this on a headless server
MaxStartups 10:30:60  # start dropping new connections above 10 pending, reject all above 60
plaintext

Add fail2ban. Even with key-only auth, bots can waste server resources by hammering connections. fail2ban watches your SSH logs and bans IPs after repeated failures. A 24-hour ban after 3 failed attempts is a reasonable starting point, matching MaxAuthTries so the ban triggers the moment they exhaust their attempts.

# /etc/fail2ban/jail.local
[sshd]
enabled  = true
port     = your-ssh-port
maxretry = 3
bantime  = 86400
ini

Whitelist users explicitly. List only the accounts that actually need SSH access using AllowUsers. Any account not on that list cannot log in over SSH, even with a valid key. This matters if a compromised service account somehow gets a key added.

AllowUsers appuser
plaintext

If you want to audit your current state rather than configure from scratch, a few scripts from sysadmin-scripts are useful here. ssh-audit.sh checks your sshd configuration for common weaknesses and gives a CLEAN, WARNING, or CRITICAL verdict with the exact command to fix each finding. user-audit.sh scans for UID 0 duplicates, accounts with empty passwords, unexpected sudo access, and SSH authorized keys across all home directories. fail2ban-report.sh gives you a per-jail summary, top offending IPs, and recent ban events. Useful for a quick picture of what is actually hitting your server.


Layer 2: Network, Lock Down What Is Reachable#

A typical self-hosted web app runs multiple processes: a backend API on one port, maybe a frontend server on another. None of these should be directly reachable from the internet. Only nginx should face the outside world.

Bind to localhost. When your app starts, configure it to listen on 127.0.0.1, not 0.0.0.0. The difference is significant. 0.0.0.0 listens on all interfaces including your public IP. 127.0.0.1 only listens on the loopback interface, unreachable from outside the machine.

// Good: only reachable locally
server.ListenAndServe("127.0.0.1:8080", handler)

// Exposes the port to the public internet
server.ListenAndServe(":8080", handler)
go

Most frameworks read the bind address from an environment variable. Set HOST=127.0.0.1 alongside your PORT and make sure your app actually reads it. It is easy to set HOST in an env file and then have the code ignore it entirely.

Back it up with iptables. Even if the app binds correctly, an explicit firewall rule adds a second line of defence:

# Allow localhost, drop everything else
iptables -I INPUT -p tcp --dport 8080 ! -s 127.0.0.1 -j DROP

# IPv6: blanket drop
ip6tables -I INPUT -p tcp --dport 8080 -j DROP

# Persist across reboots
netfilter-persistent save
bash

One thing to watch: use ! -s 127.0.0.1 -j DROP rather than a plain -j DROP. If you need to debug via an SSH tunnel, the tunnel traffic comes from 127.0.0.1. A blanket DROP silently breaks it.

open-ports-audit.sh from the same sysadmin-scripts collection lists every listening port with its process name and owner, compares it against a whitelist you define, and flags anything unexpected. Worth running after any deployment or infrastructure change.


Layer 3: Process Isolation via systemd#

Running your app as root is a bad idea. If the process gets compromised, the attacker gets root. Run it as an unprivileged user instead, one with write access to the app directory and nowhere else.

[Service]
User=appuser
Group=appuser
ini

systemd has built-in sandboxing directives that add meaningful isolation at no extra cost:

NoNewPrivileges=yes     # process cannot gain privileges via SUID binaries
PrivateTmp=yes          # isolated /tmp, cannot see other processes' temp files
ProtectHome=yes         # system home directories are invisible to this process
ProtectSystem=strict    # filesystem is read-only except for explicitly allowed paths
ReadWritePaths=/var/www/myapp  # the one directory the app actually needs
ini

These are defense-in-depth. If your app gets exploited, the attacker ends up stuck inside a box. They cannot read sensitive system directories, cannot modify system files, and cannot escalate privileges. The damage radius shrinks dramatically.

One gotcha: ProtectHome=yes breaks any runtime that writes to home directories for caching or telemetry. Check your runtime’s docs for an env var to redirect or disable it rather than removing the protection entirely.


Layer 4: HTTP Security Headers#

Once traffic reaches nginx, a few response headers tell browsers how to handle your content. Put these in a shared snippet included by every vhost:

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
server_tokens off;
nginx

HSTS is the most important one. Once a browser sees it, it will refuse to connect to your domain over plain HTTP for the duration of max-age. Two years is the standard recommendation.

server_tokens off hides your nginx version from response headers. There is no reason to advertise which version you are running to anyone scanning for known vulnerabilities.

Content Security Policy needs to be per-vhost, not global. A CSP defines which origins a page is allowed to load scripts, styles, fonts, and make API calls to. Different apps have different requirements. A shared CSP either ends up so permissive it is useless, or it silently breaks something. Define it individually for each vhost based on what that app actually loads.


Layer 5: Consider a WAF on a Separate VPS#

The four layers above protect the server itself. A Web Application Firewall (WAF) works one level higher, inspecting HTTP traffic before it even reaches nginx, and blocking common attack patterns like SQL injection, XSS, and malicious bots.

SafeLine is a self-hostable WAF worth looking at. The recommended setup is to run it on a separate VPS under the same cloud provider, connected via a private network to your main server. The reason for a separate VPS is practical: WAF software can be resource-intensive depending on traffic volume, it has its own port requirements that can conflict with existing services, and keeping it isolated means you can scale it independently without touching your app server.

The traffic flow looks like this:

Internet -> WAF VPS (inspects & filters) -> App VPS (your nginx + apps)
plaintext

Your app server never receives traffic directly from the internet. Everything passes through the WAF first.


Layer 6: TLS and Certificate Auto-Renewal#

Every site should be HTTPS only. Not just the login page. Everything. Plain HTTP leaks session cookies, exposes content to network-level tampering, and modern browsers are actively warning users away from it.

Let’s Encrypt makes this free. Certbot handles issuance and renewal:

# Issue a cert for your domain
certbot certonly --nginx -d yourdomain.com

# Test auto-renewal
certbot renew --dry-run
bash

The renewal part matters as much as the issuance. Let’s Encrypt certificates expire after 90 days. Set up a systemd timer or cron job to run certbot renew twice a day. That way you are never caught with an expired cert.

Redirect HTTP to HTTPS at the nginx level so there is no way to accidentally serve content over plain HTTP:

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}
nginx

HSTS from Layer 4 completes the picture. Once the browser has seen the HSTS header over HTTPS, it will refuse to even attempt an HTTP connection to that domain in the future.


Layer 7: Sit Behind a CDN#

A CDN does more than cache static files. When your server is behind Cloudflare or a similar provider, your real server IP is hidden from the public internet. Attackers scanning for your origin have a harder time finding where to actually send traffic.

More importantly, volumetric DDoS attacks hit the CDN edge, not your server. A flood of traffic that would knock over a small VPS gets absorbed across hundreds of edge nodes. This is one of the most cost-effective security layers available, and the free tier of most CDN providers covers everything a personal site or small project needs.

A few things to verify when using a CDN:

Configure your origin server to only accept connections from the CDN’s IP ranges, not the open internet. Otherwise the protection is cosmetic. An attacker who discovers your real IP can bypass the CDN entirely and hit your server directly.

Make sure your SSL configuration is set to “Full (strict)” mode if you use Cloudflare. The “Flexible” mode means traffic between Cloudflare and your server travels unencrypted, which defeats the point of having a certificate.


Layer 8: Automatic Security Updates#

A server that is never updated is a server that will eventually be compromised. Known CVEs get published, exploit code follows, and bots start scanning for vulnerable versions within days.

On Ubuntu and Debian-based systems, unattended-upgrades handles this:

apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
bash

The default configuration applies security updates only, not every available update. That is the right call. You want patches for known vulnerabilities applied automatically. You do not want an unattended dist-upgrade breaking something on a production server at 3am.

Verify it is actually running:

systemctl status unattended-upgrades
bash

Check the logs periodically at /var/log/unattended-upgrades/ to confirm packages are being updated. It is easy to install and forget, then discover months later that it was silently failing.


Layer 9: Backups Are a Security Layer#

Most people think of backups as an ops concern. They are also a security concern. Ransomware encrypts your data and demands payment. A disgruntled ex-contributor deletes your database. A breach happens and you need to restore to a known-clean state. In all of these cases, backups are the only thing that lets you recover without starting over.

A backup strategy needs three things to be useful:

Offsite storage. A backup on the same server it is protecting is not a backup. If the server is compromised or destroyed, you lose both. Store backups on a separate machine, ideally under a different provider.

Automation. A backup you have to remember to run is a backup that will not exist when you need it. Use a systemd timer or cron job to run backups daily. Log the output somewhere you can check.

Tested restoration. A backup you have never restored from is a backup you cannot trust. Periodically restore a backup to a test environment and verify the data is intact and the app runs. Do this before you need it, not during an incident.

# Example: back up a SQLite database daily via systemd timer
# /etc/systemd/system/myapp-backup.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'cp /var/www/myapp/data/app.db /backups/app-$(date +%%Y%%m%%d).db'
bash

Two retained copies is a minimum. If you can afford more, keep more.

If you want a ready-made starting point, backup.sh from sysadmin-scripts handles SQLite hot backups, directory archives, and individual files like .env. It packages everything into a timestamped .tar.gz, uploads to a remote server via SCP, and rotates old copies both locally and remotely. Configure the paths at the top of the file, add a cron entry, and it runs itself.


What This Does Not Cover#

These nine layers cover the common ground for a self-managed server. They are not exhaustive.

Application-level vulnerabilities like SQL injection, XSS, and broken access control live in your code. No firewall rule protects you from a login endpoint that concatenates user input directly into a query. Use prepared statements, validate input on the server, and hash passwords with bcrypt or argon2.

Dependency vulnerabilities need active monitoring. Go has govulncheck, npm has npm audit. Run them before deploying, not after something breaks. Tools like Dependabot can automate this in CI.

Secrets hygiene is on you. Environment files should be chmod 600, never committed to git, and not world-readable. A world-readable secrets file on a server is a plaintext credential dump waiting to be found.

Mandatory access control goes deeper than systemd sandboxing. AppArmor (enabled by default on Ubuntu) and SELinux define system-wide policies restricting what files and syscalls any process can access. Worth learning if you run services that handle sensitive data.

Kernel hardening via sysctl parameters tightens the network stack itself, things like SYN flood protection and source address verification. Reasonable defaults exist but they are not always on out of the box.

Two-factor authentication for SSH adds a TOTP layer on top of key-based auth. If a private key is ever stolen, 2FA is the last line. Look into libpam-google-authenticator or similar.

Log monitoring and alerting means being notified when something unusual happens, not just reacting after the fact. fail2ban reacts to patterns but does not alert you. A spike in 403s, repeated probing of sensitive paths, or a login at 3am from an unknown country are all worth knowing about in real time.


Going Deeper: WordPress#

If you run WordPress, the attack surface is wider. WordPress powers a large share of the web, which makes it a high-value target. Automated scanners specifically probe for outdated plugins, exposed wp-admin, and known vulnerabilities in popular themes.

The same principles apply: SSH hardening, firewall, unprivileged process user, security headers. But the WordPress-specific layer on top of that is a different topic. This course covers it in depth, including WAF configuration: WordPress Security.


Is This Right for You?#

This setup makes sense if you are self-hosting on a VPS and want to understand what you are actually running. It requires no paid tools and no external services. Just sshd, iptables, systemd, and nginx doing their jobs.

It is not a complete answer for an app handling sensitive user data at scale. For that you would want a proper secrets manager, structured audit logging, network-level intrusion detection, and a security review before launch.

For a personal site or small project on a VPS you own: this is the setup I run. 23,000 failed login attempts later, nothing has gotten through. The bots are still out there. They just keep hitting a wall.

Enjoyed this post?

Get Linux tips, sysadmin war stories, and new posts delivered to your inbox.

No spam. Unsubscribe anytime.

Attacked Every 23 Seconds. Why I'm Not Worried.
https://srmdn.com/blog/vps-security-layers
Author srmdn
Published at March 9, 2026