project 02
Drippin' Pools by Gio
A real business's website, built end-to-end and hosted from my own homelab — then put through a full four-layer security audit.
A friend's pool-cleaning business needed a real online presence, so I built one — not through a website builder, but the same way a small production app actually gets shipped: a Flask backend, a real reverse proxy, a real domain, and a server I run myself.
Stack
| Frontend | HTML5, CSS3, JavaScript |
| Backend | Python, Flask |
| App server | Gunicorn |
| Reverse proxy | Nginx |
| OS | Ubuntu Linux |
| DNS / edge | Cloudflare (Tunnel — hides the origin IP entirely) |
| Hosting | Self-hosted, DMZ VLAN of my homelab |
Features
A contact form (name, phone, email, service requested, referral source, message) sends a formatted lead email straight to the business owner via Gmail SMTP, with clickable phone/email links for fast follow-up. A referral-tracking field (Person / Facebook / Yelp / Google / "Saw the Truck" / Other) helps the owner see which marketing channels actually bring customers in. The site is fully responsive, with a dedicated mobile hero image after the original one scaled incorrectly on smaller screens, and links out to the business's Facebook and Yelp pages.
Security — a four-layer audit
Real traffic means real attackers scanning it constantly. I worked through the whole stack layer by layer, like a home inspection — what's solid, what needs work, what could bite later.
Layer 0 — Cloudflare
Cloudflare Tunnel was already hiding the home network's IP correctly — nobody can find the real server by looking up the domain. I turned on three things that were off by default: Bot Fight Mode (challenges suspected automated traffic before it reaches Nginx), Block AI Bots (stops scraping/training crawlers), and AI Labyrinth (sends bots that ignore the block into an infinite maze of fake pages).
Layer 1 — Network firewall (OPNsense)
Suricata (inline IPS mode) had already blocked 27 inbound SSH brute-force attempts from known-bad IPs before they touched anything inside. DMZ isolation was correct — the server can reach the internet but nothing internal. One gap: Suricata only watched the WAN interface, not the DMZ, so malware calling home from a compromised DMZ box would go unseen. Fixed by routing the DMZ's DNS through my Pi-hole instead of a public resolver — every domain the server looks up now gets logged and checked against a known-bad-domain blocklist automatically.
Layer 2 — The server itself (OS & SSH)
Login history was clean, but a port scan turned up two real issues: an old test/demo service left reachable from the outside longer than intended, and SSH still relying on password auth with no protection against repeated login attempts. Both were closed the same day — the exposed service was removed, fail2ban was installed and tuned, and SSH was switched to key-only authentication.
Specifics are kept general here on purpose — the audit process and the fixes are what's worth showing, not a map of what used to be open.
Layer 3 — The web app (Nginx & Flask)
Logs showed only normal background noise — WordPress-login probes that got harmless 404s, a self-identifying security-vendor scanner, and social/AI crawlers. But bots were auto-filling the contact form to spam it. Fixed with a honeypot: a hidden field real users never see; if it's filled in, the backend fakes a success response and silently discards the submission, so the bot never adapts. Also added a robots.txt disallowing the contact route to cut down on scanner noise.
Anti-spam hardening (later addition)
The honeypot alone stopped working once spam scripts started POSTing directly to the contact endpoint, skipping the rendered page — and the honeypot field — entirely. Three more layers went in on top:
| CSRF protection | Flask-WTF issues a one-time token on page load; submissions without it are auto-rejected |
| Cloudflare Turnstile | Invisible human-verification widget on the form itself |
| Pattern-based filter | Flags submissions containing URLs in the message/name fields or known throwaway email patterns |
Current security stack
| Cloudflare | Hides origin IP, bot blocking, DDoS protection |
| HTTPS / TLS | All traffic encrypted end-to-end, including SMTP to Gmail |
| Suricata IPS | Blocks known-bad IPs before they reach the network |
| OPNsense DMZ | Isolates the server from the rest of the homelab |
| Pi-hole DNS | Logs all outbound DNS, blocks known C2 domains |
| fail2ban | Bans SSH brute-forcers after 3 attempts |
| SSH keys only | No password to brute-force |
| Credential management | Secrets kept in environment variables, never in source code; app-specific passwords instead of real account passwords |
| CSRF + Turnstile + honeypot | Three layers of contact-form spam prevention |
| Rate limiting | Contact form limited per IP |
| Input sanitization / XSS prevention | All submitted input validated and stripped of HTML/JS server-side before use |
Related work
This site runs in the DMZ VLAN of my own homelab, isolated from everything else running there.