Today was one of those days where something that should have been simple turned into a deep dive across multiple layers of the stack. And honestly, those are the days where I learn the most.
My original goal was straightforward: replace a third-party contact form with my own solution so messages would stay on my infrastructure and emails would come directly from my domain.
What followed was a full end-to-end exercise in debugging, systems thinking, and understanding how modern web stacks actually behave in the real world.
The Goal
I wanted a contact form that:
- Did not redirect users to a third-party site
- Used my domain email address
- Was fully self-hosted
- Worked cleanly behind my existing reverse proxy setup
- Gave clear feedback to users when a message was sent
In short, I wanted control and transparency instead of my users having to press the back button to get back to my site after asking a question. Also- I thought I could make it look better if I just used my own system.
Backend: Building the Contact API
I started by creating a small contact API using Flask and containerizing it with Docker.
Key decisions:
- Minimal Flask app
- Gunicorn for production serving
- Environment variables for SMTP configuration
- No external dependencies beyond what was necessary
The container exposed a single endpoint:
POST /api/contact
This endpoint accepts JSON, validates the payload, and sends an email using Zoho SMTP.
Once built, I verified it locally using curl and confirmed I was getting proper HTTP responses (405, then 200 once routing was correct).
Reverse Proxy and Networking
This API had to live behind my existing infrastructure:
- Nginx serving the static Hugo site
- Traefik handling routing
- Cloudflare Tunnel for external access
The tricky part was realizing that:
- Traefik and the API container were on different Docker networks
- Traefik could not route traffic to a container it could not see
After attaching the API container to the same Docker network as Traefik and defining the correct router rules using labels, requests started reaching the API properly.
At that point, I was able to successfully hit the endpoint through the full chain: Browser -> Cloudflare -> Traefik -> API container
Frontend: The Contact Form
On the frontend, I replaced the old Formspree-based form with my own HTML form and JavaScript.
Instead of relying on default browser form submission, I handled the process manually using fetch to send a JSON payload to the API endpoint.
This gave me:
- Full control over validation
- No page reloads
- Clean success and error handling
Debugging the Hard Part: JavaScript Events
This is where things got interesting.
Even though my JavaScript was loading and my event handler was attached, the form kept submitting as a normal GET request and reloading the page.
After a lot of investigation, console logging, and DOM inspection, I found the root cause:
My page had a heading element with an automatically generated ID that matched the ID I was trying to use for the form.
In other words:
- My JavaScript thought it was talking to a form
- But it was actually referencing an H3 element
Because of that, FormData was broken and the browser was falling back to default behavior.
Once I:
- Gave the actual form a unique ID
- Switched my selector to target the form by class
- Avoided ID collisions entirely
Everything immediately started working.
This was a great reminder that bugs are often not where you expect them to be.
Improving the User Experience
Originally, the success message appeared as inline text below the submit button.
To make things feel more polished, I added a modal popup that appears when the message is sent successfully.
This provided:
- Clear visual confirmation
- No page reload
- A more professional feel overall
The modal is fully custom, lightweight, and requires no external libraries.
Email Delivery
The final step was confirming that emails were actually being delivered.
Using Zoho Mail SMTP with proper SPF, DKIM, and authenticated credentials, I was able to send messages reliably from my domain address.
Once everything was verified:
- API returned 200 OK
- Modal appeared
- Email arrived in my inbox
End to end success.
What I Learned
This project reinforced several key lessons:
- Small features can span many layers of the stack
- Networking and routing issues often masquerade as application bugs
- Browser behavior is not always obvious
- Debugging systematically beats guessing every time
- Understanding your tools matters more than memorizing commands
Most importantly, I walked away with a system I fully understand and control.
Final Thoughts
This was not a copy-paste project. It required reading logs, inspecting requests, tracing traffic, and thinking carefully about how each component interacted with the others.
Projects like this are exactly why I maintain a homelab and build things myself. Breaking things, fixing them, and understanding why they broke is where real learning happens.
And now, my site has a fully self-hosted contact system to prove it.