Tools: Breaking: Securing Your Home Server — UFW, Fail2Ban, SSH Hardening, and Lessons Learned

Tools: Breaking: Securing Your Home Server — UFW, Fail2Ban, SSH Hardening, and Lessons Learned

Step 1: Set up UFW firewall properly

Step 2: Install and configure Fail2Ban

Step 3: Harden SSH

Change the SSH port

Disable root login

Disable password authentication (use keys only)

Apply the changes

Step 4: Enable automatic security updates

Step 5: Set up basic monitoring

Install Uptime Kuma

Step 6: Protect your Oracle VPS the same way

Lessons learned after running this for a while

The finished product

What's next In Part 4, your server went live on the internet. Which means within 24 hours, bots from around the world started probing it for weaknesses. This isn't paranoia — it's reality. Any public-facing server gets hit with hundreds of automated attacks daily. This is the final part of the series. We'll lock everything down so you can sleep at night. UFW (Uncomplicated Firewall) blocks every port except the ones you explicitly allow. Default-deny is the only sane approach for a public server. You should see only the ports you opened. Everything else is blocked. ⚠️ Don't enable UFW over an SSH session without allowing SSH first. You'll lock yourself out. Always sudo ufw allow 22/tcp before sudo ufw enable. Fail2Ban watches your log files and bans IPs that show malicious behavior (failed logins, brute-force attempts, etc.). Create a local config (never edit jail.conf directly — it gets overwritten on updates): Create the Nextcloud filter: Within a day or two, you'll see dozens of IPs banned — these are the bots constantly probing your server. SSH is the front door to your server. Default settings are a huge target for bots. Pick any port between 1024-49151 (I'm using 2299 as an example). This alone reduces bot attacks by ~90% since most bots only scan port 22. Before doing this, make sure your SSH key works. Generate one if you haven't: Copy the public key to your server: Once you confirm key login works, disable passwords in sshd_config: From now on, SSH with: Unpatched servers are the easiest targets. Set up automatic updates for security patches: Select Yes when prompted. Your server now automatically installs security updates overnight. You want to know when something breaks — not find out from a family member saying "the photos site is down." Uptime Kuma is a simple self-hosted monitoring tool. Add it to your Docker setup: Add this service below nextcloud: Open http://192.168.1.100:3001, create an admin account, and add monitors for: Set up email or Telegram notifications so you get pinged when anything goes down. Don't forget — your Oracle VPS is also public-facing. SSH into it and apply the same hardening: Both servers need the same level of security. The chain is only as strong as its weakest link. Things I wish I'd known on day one: Use Ethernet if the server is anywhere near your router. WiFi drops happen. They're rare but always at the worst time — usually when you're traveling and need a file. Wired connections are boringly reliable. Set BIOS to auto-power-on. After a power cut, you want the server to boot itself. Look for "AC Power Recovery" or "Restore on AC Power Loss" in BIOS and set to "Always On." All your services (Docker, frpc, Tailscale) will auto-start on boot, so full recovery takes 2-3 minutes with zero intervention. Back up Nextcloud's config directory. If your server's drive fails, the data is gone but losing the config directory means you can't even rebuild. I rsync ~/server/config to an external drive weekly. Tailscale is the unsung hero. If I had to cut any part of this stack, the public VPS path would go. Tailscale alone covers 95% of real-world use. The VPS exists only so family members can open a link in a browser. Don't expose services you don't need. Every open port is a potential vulnerability. If you don't need Samba accessible remotely, don't expose it. Only Nextcloud goes through the public tunnel. Docker makes recovery easy. When something breaks, I can nuke the container and rebuild from docker-compose.yml in 30 seconds. All state lives in mounted volumes. This is the biggest practical advantage of running services in containers. Monitor what matters. Disk space is the #1 thing to watch. Photos pile up fast. When your drive hits 90%, things start failing silently. Uptime Kuma + a simple disk space check saves you from nasty surprises. After 5 posts, you have: ✅ A complete personal cloud on an old laptop

✅ Phone auto-backup replacing Google Photos✅ Desktop sync replacing Google Drive✅ NAS file sharing via Samba✅ Private remote access via Tailscale✅ Public access via custom domain with SSL✅ Firewall, brute-force protection, and SSH hardening✅ Automated monitoring✅ Total recurring cost: ~₹850/year for a domain All running on hardware that was collecting dust. The series is done, but the server isn't. Things I'm planning to add over the coming months: I'll write about each of these as I add them. If any of it sounds interesting, follow me here. All config files from this entire series are on GitHub:

👉 github.com/sasrath/homecloud Thanks for following along with this series. Whether you built the whole thing or just read for context, I hope it was useful. Questions, feedback, or your own war stories from self-hosting? Drop them in the comments. I read and reply to everything. Your cloud. Your data. Your house. 🏠 Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# Start fresh -weight: 600;">sudo ufw default deny incoming -weight: 600;">sudo ufw default allow outgoing # Allow SSH (we'll change this port in Step 3) -weight: 600;">sudo ufw allow 22/tcp # Allow Nextcloud on local network -weight: 600;">sudo ufw allow 8888/tcp # Allow Samba on local network -weight: 600;">sudo ufw allow 137/udp -weight: 600;">sudo ufw allow 138/udp -weight: 600;">sudo ufw allow 139/tcp -weight: 600;">sudo ufw allow 445/tcp # Allow all traffic on Tailscale interface -weight: 600;">sudo ufw allow in on tailscale0 # Enable UFW -weight: 600;">sudo ufw -weight: 500;">enable # Start fresh -weight: 600;">sudo ufw default deny incoming -weight: 600;">sudo ufw default allow outgoing # Allow SSH (we'll change this port in Step 3) -weight: 600;">sudo ufw allow 22/tcp # Allow Nextcloud on local network -weight: 600;">sudo ufw allow 8888/tcp # Allow Samba on local network -weight: 600;">sudo ufw allow 137/udp -weight: 600;">sudo ufw allow 138/udp -weight: 600;">sudo ufw allow 139/tcp -weight: 600;">sudo ufw allow 445/tcp # Allow all traffic on Tailscale interface -weight: 600;">sudo ufw allow in on tailscale0 # Enable UFW -weight: 600;">sudo ufw -weight: 500;">enable # Start fresh -weight: 600;">sudo ufw default deny incoming -weight: 600;">sudo ufw default allow outgoing # Allow SSH (we'll change this port in Step 3) -weight: 600;">sudo ufw allow 22/tcp # Allow Nextcloud on local network -weight: 600;">sudo ufw allow 8888/tcp # Allow Samba on local network -weight: 600;">sudo ufw allow 137/udp -weight: 600;">sudo ufw allow 138/udp -weight: 600;">sudo ufw allow 139/tcp -weight: 600;">sudo ufw allow 445/tcp # Allow all traffic on Tailscale interface -weight: 600;">sudo ufw allow in on tailscale0 # Enable UFW -weight: 600;">sudo ufw -weight: 500;">enable -weight: 600;">sudo ufw -weight: 500;">status verbose -weight: 600;">sudo ufw -weight: 500;">status verbose -weight: 600;">sudo ufw -weight: 500;">status verbose -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y -weight: 600;">sudo nano /etc/fail2ban/jail.local -weight: 600;">sudo nano /etc/fail2ban/jail.local -weight: 600;">sudo nano /etc/fail2ban/jail.local [DEFAULT] # Ban for 1 hour after 5 failed attempts in 10 minutes bantime = 3600 findtime = 600 maxretry = 5 # Don't ban localhost or your local network ignoreip = 127.0.0.1/8 192.168.1.0/24 [sshd] enabled = true port = ssh filter = sshd logpath = /var/log/auth.log maxretry = 3 [nextcloud] enabled = true port = 80,443,8888 protocol = tcp filter = nextcloud maxretry = 5 bantime = 3600 findtime = 600 logpath = /srv/nas/nextcloud/nextcloud.log [DEFAULT] # Ban for 1 hour after 5 failed attempts in 10 minutes bantime = 3600 findtime = 600 maxretry = 5 # Don't ban localhost or your local network ignoreip = 127.0.0.1/8 192.168.1.0/24 [sshd] enabled = true port = ssh filter = sshd logpath = /var/log/auth.log maxretry = 3 [nextcloud] enabled = true port = 80,443,8888 protocol = tcp filter = nextcloud maxretry = 5 bantime = 3600 findtime = 600 logpath = /srv/nas/nextcloud/nextcloud.log [DEFAULT] # Ban for 1 hour after 5 failed attempts in 10 minutes bantime = 3600 findtime = 600 maxretry = 5 # Don't ban localhost or your local network ignoreip = 127.0.0.1/8 192.168.1.0/24 [sshd] enabled = true port = ssh filter = sshd logpath = /var/log/auth.log maxretry = 3 [nextcloud] enabled = true port = 80,443,8888 protocol = tcp filter = nextcloud maxretry = 5 bantime = 3600 findtime = 600 logpath = /srv/nas/nextcloud/nextcloud.log -weight: 600;">sudo nano /etc/fail2ban/filter.d/nextcloud.conf -weight: 600;">sudo nano /etc/fail2ban/filter.d/nextcloud.conf -weight: 600;">sudo nano /etc/fail2ban/filter.d/nextcloud.conf [Definition] failregex = ^.*Login failed: .* \(Remote IP: <HOST>\).*$ ^.*"remoteAddr":"<HOST>".*Trusted domain error.*$ ignoreregex = [Definition] failregex = ^.*Login failed: .* \(Remote IP: <HOST>\).*$ ^.*"remoteAddr":"<HOST>".*Trusted domain error.*$ ignoreregex = [Definition] failregex = ^.*Login failed: .* \(Remote IP: <HOST>\).*$ ^.*"remoteAddr":"<HOST>".*Trusted domain error.*$ ignoreregex = -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status fail2ban -weight: 600;">sudo fail2ban-client -weight: 500;">status sshd -weight: 600;">sudo fail2ban-client -weight: 500;">status sshd -weight: 600;">sudo fail2ban-client -weight: 500;">status sshd -weight: 600;">sudo nano /etc/ssh/sshd_config -weight: 600;">sudo nano /etc/ssh/sshd_config -weight: 600;">sudo nano /etc/ssh/sshd_config PermitRootLogin no PermitRootLogin no PermitRootLogin no # On your local machine (Mac/Linux/Windows with OpenSSH) ssh-keygen -t ed25519 -f ~/.ssh/homeserver_key # On your local machine (Mac/Linux/Windows with OpenSSH) ssh-keygen -t ed25519 -f ~/.ssh/homeserver_key # On your local machine (Mac/Linux/Windows with OpenSSH) ssh-keygen -t ed25519 -f ~/.ssh/homeserver_key ssh-copy-id -i ~/.ssh/homeserver_key.pub your-username@192.168.1.100 ssh-copy-id -i ~/.ssh/homeserver_key.pub your-username@192.168.1.100 ssh-copy-id -i ~/.ssh/homeserver_key.pub your-username@192.168.1.100 ssh -i ~/.ssh/homeserver_key your-username@192.168.1.100 ssh -i ~/.ssh/homeserver_key your-username@192.168.1.100 ssh -i ~/.ssh/homeserver_key your-username@192.168.1.100 PasswordAuthentication no PubkeyAuthentication yes PasswordAuthentication no PubkeyAuthentication yes PasswordAuthentication no PubkeyAuthentication yes # Update UFW for the new SSH port -weight: 600;">sudo ufw allow 2299/tcp -weight: 600;">sudo ufw delete allow 22/tcp # Restart SSH -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart sshd # Update UFW for the new SSH port -weight: 600;">sudo ufw allow 2299/tcp -weight: 600;">sudo ufw delete allow 22/tcp # Restart SSH -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart sshd # Update UFW for the new SSH port -weight: 600;">sudo ufw allow 2299/tcp -weight: 600;">sudo ufw delete allow 22/tcp # Restart SSH -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart sshd ssh -p 2299 -i ~/.ssh/homeserver_key your-username@192.168.1.100 ssh -p 2299 -i ~/.ssh/homeserver_key your-username@192.168.1.100 ssh -p 2299 -i ~/.ssh/homeserver_key your-username@192.168.1.100 -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install unattended-upgrades -y -weight: 600;">sudo dpkg-reconfigure -plow unattended-upgrades -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install unattended-upgrades -y -weight: 600;">sudo dpkg-reconfigure -plow unattended-upgrades -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install unattended-upgrades -y -weight: 600;">sudo dpkg-reconfigure -plow unattended-upgrades cd ~/server nano -weight: 500;">docker-compose.yml cd ~/server nano -weight: 500;">docker-compose.yml cd ~/server nano -weight: 500;">docker-compose.yml uptime-kuma: image: louislam/uptime-kuma:latest container_name: uptime-kuma -weight: 500;">restart: always ports: - "3001:3001" volumes: - ./uptime-kuma:/app/data uptime-kuma: image: louislam/uptime-kuma:latest container_name: uptime-kuma -weight: 500;">restart: always ports: - "3001:3001" volumes: - ./uptime-kuma:/app/data uptime-kuma: image: louislam/uptime-kuma:latest container_name: uptime-kuma -weight: 500;">restart: always ports: - "3001:3001" volumes: - ./uptime-kuma:/app/data -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d # Install Fail2Ban -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y # Same SSH hardening (key-only, change port, -weight: 500;">disable root) # Same UFW rules — only open 80, 443, 7000 -weight: 600;">sudo ufw default deny incoming -weight: 600;">sudo ufw allow 2299/tcp # your custom SSH port -weight: 600;">sudo ufw allow 80/tcp -weight: 600;">sudo ufw allow 443/tcp -weight: 600;">sudo ufw allow 7000/tcp -weight: 600;">sudo ufw -weight: 500;">enable # Install Fail2Ban -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y # Same SSH hardening (key-only, change port, -weight: 500;">disable root) # Same UFW rules — only open 80, 443, 7000 -weight: 600;">sudo ufw default deny incoming -weight: 600;">sudo ufw allow 2299/tcp # your custom SSH port -weight: 600;">sudo ufw allow 80/tcp -weight: 600;">sudo ufw allow 443/tcp -weight: 600;">sudo ufw allow 7000/tcp -weight: 600;">sudo ufw -weight: 500;">enable # Install Fail2Ban -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y # Same SSH hardening (key-only, change port, -weight: 500;">disable root) # Same UFW rules — only open 80, 443, 7000 -weight: 600;">sudo ufw default deny incoming -weight: 600;">sudo ufw allow 2299/tcp # your custom SSH port -weight: 600;">sudo ufw allow 80/tcp -weight: 600;">sudo ufw allow 443/tcp -weight: 600;">sudo ufw allow 7000/tcp -weight: 600;">sudo ufw -weight: 500;">enable - UFW firewall — only allow what you need - Fail2Ban — auto-ban IPs that try to brute-force your server - SSH hardening — custom port, key-only login, no root access - Basic monitoring — know when something breaks - Lessons learned after running this for a while - https://files.yourdomain.com (public endpoint) - http://192.168.1.100:8888 (local Nextcloud) - Your Tailscale IP (private endpoint) - Immich — self-hosted Google Photos alternative with face recognition and AI search - Automated off-site backup — nightly rsync to an external drive + occasional snapshots to a cheap cloud - Jellyfin — self-hosted media server for streaming movies to any device - Second storage drive — a 4TB USB drive for expansion