How I Built a Simple Contact Form API for My Static Sites with FastAPI and Docker
How I Built a Simple Contact Form API for My Static Sites with FastAPI and Docker
AI helped me create a lot of beautiful static websites.
That part was easy.
The hard part came right after: what happens when someone fills out the contact form?
Static sites are great for speed, simplicity, and low-cost hosting. But they do not process form submissions on their own. I needed one small backend service that I could reuse across all my static projects. It had to be simple, secure, easy to deploy, and flexible enough to connect with different email providers.
So I built a small project called fastapi-contactform. It is a FastAPI-based API that accepts contact form submissions, verifies them with Cloudflare Turnstile, and forwards them through an email service such as Mailjet or SendGrid. The repository uses FastAPI, Pydantic, requests, Docker, and environment variables for configuration.
In this post, I want to walk through the journey and explain how this tiny service solves a very real problem for static websites.
The Problem I Wanted to Solve
When I started creating more static sites, I kept running into the same issue:
- I could generate a nice contact form in HTML
- I could style it beautifully
- I could deploy the site anywhere
- But I still needed a safe way to receive messages
I did not want to build a heavy backend for every project.
I also did not want to manually wire each site to a separate custom server. That would create too much repeated work. I wanted one reusable API that any static site could call.
The goal became very clear:
- Accept contact form data from multiple static sites
- Validate the incoming data
- Block spam and bots
- Send the message to my email inbox
- Lock it down so random origins cannot abuse it
- Package it in Docker so deployment stays simple
That is exactly what this project does. The app defines a /contact endpoint, validates form fields with Pydantic, verifies a Turnstile token, and then sends the message through a provider-specific mail service.
Why I Picked FastAPI
I wanted a backend framework that felt lightweight and modern.
FastAPI was a strong fit because it gives me:
- Clean route definitions
- Automatic request validation
- Good developer experience
- Easy JSON APIs
- A structure that stays small for projects like this
In the project, the API is built around a FastAPI() app and a ContactForm model. The model includes name, email, subject, message, and turnstileToken, with field length rules and email validation handled by Pydantic.
Here is the basic shape of the form model used in the project:
class ContactForm(BaseModel):
name: str = Field(..., min_length=2, max_length=100)
email: EmailStr
subject: str = Field(..., min_length=2, max_length=150)
message: str = Field(..., min_length=5, max_length=5000)
turnstileToken: str
This is one of my favorite parts of the project because it makes validation explicit and easy to understand.
The Core Idea: One API for Many Static Sites
Instead of building one backend per website, I built one small service that can sit behind all of them.
A static site just needs to send a POST request to the API.
That means the frontend stays simple. The static site only needs:
- A contact form
- A Turnstile widget
- A fetch request to the API endpoint
The backend does the sensitive work.
This separation is useful because static sites should stay static. They are best when they focus on content and presentation, while the API handles validation, security, and delivery.
How the Request Flows
Letās break the flow into small steps.
Step 1: The frontend sends the form
A website visitor fills in:
- Name
- Subject
- Message
The frontend also includes a Cloudflare Turnstile token.
Step 2: FastAPI validates the payload
Before doing anything else, the API checks that:
- The name is long enough
- The email looks valid
- The subject is present
- The message is not too short
- The Turnstile token exists
This happens through the ContactForm model.
Step 3: The API verifies the Turnstile token
The project sends a POST request to Cloudflareās Turnstile verification endpoint using the secret stored in environment variables. If verification fails, the API returns a 400 error with "Bot verification failed".
Step 4: The API sends the email
If the submission passes validation and bot protection, the app calls mail_service.send_contact_email(...).
That mail service is selected from configuration. The project currently supports Mailjet and SendGrid through separate classes behind a common interface.
Step 5: The API returns success
If everything works, the response is:
{
"success": true
}
That comes directly from the /contact route.
Why Security Mattered So Much
A contact form endpoint sounds small, but it can be abused very easily.
Without protection, people or bots could:
- Spam the endpoint
- Flood my inbox
- Trigger email provider limits
- Abuse the service from other domains
- Submit junk data over and over
So I added a few important protections.
1. Cloudflare Turnstile
The API expects a turnstileToken and verifies it server-side using TURNSTILE_SECRET. This is important because frontend-only checks are not enough. The server must decide whether the submission is trusted.
2. CORS allowlist
The app reads CORS_ALLOWED_ORIGINS from environment variables, parses the comma-separated values, and applies FastAPIās CORSMiddleware only when allowed origins are configured. That makes it possible to limit browser access to trusted frontend domains.
3. Required environment validation on startup
On startup, the app validates critical configuration such as TURNSTILE_SECRET and the required mail provider credentials. If something important is missing, the application raises an error early instead of failing silently at runtime.
I really like this startup validation approach because it makes deployment mistakes obvious.
The Mail Service Design
One of the nicest parts of this project is the mail abstraction.
Instead of hardcoding one provider, the code defines an abstract MailService and then implements provider-specific classes:
MailjetMailServiceSendGridMailService
The selected provider is controlled by the MAIL_PROVIDER environment variable, and the code builds the correct service using get_mail_service(). The configuration validator also checks the required variables for the selected provider.
This design helps in a few ways:
- I can switch providers without rewriting the API
- The route logic stays clean
- Provider-specific code is isolated
- Future providers can be added more easily
This is a small project, but this pattern already makes it feel much more maintainable.
The Environment Variables
The project keeps configuration outside the code using a .env.example file.
It currently includes:
TURNSTILE_SECRETLOG_LEVELCORS_ALLOWED_ORIGINSMAIL_PROVIDERTO_EMAILFROM_EMAILMAILJET_API_KEYMAILJET_API_SECRETSENDGRID_API_KEY
This is a big win because each deployment environment can have its own settings without changing application code.
A sample local .env might look like this:
TURNSTILE_SECRET=your_turnstile_secret
LOG_LEVEL=INFO
CORS_ALLOWED_ORIGINS=https://site-one.com,https://site-two.com
MAIL_PROVIDER=mailjet
TO_EMAIL=you@example.com
FROM_EMAIL=you@example.com
MAILJET_API_KEY=your_mailjet_api_key
MAILJET_API_SECRET=your_mailjet_api_secret
Packaging It with Docker
I wanted deployment to be boring.
That is why Docker was a great fit.
The repository uses a small Dockerfile based on python:3.12-slim, installs dependencies from requirements.txt, copies main.py and mail_services.py, sets PORT=8080, and starts Uvicorn with main:app.
Here is the flow in plain English:
- Start from a slim Python image
- Create the app working directory
- Install the Python packages
- Copy the application files
- Run the FastAPI app with Uvicorn
That keeps the project easy to run locally and easy to deploy to container-friendly platforms.
Example commands:
docker build -t fastapi-contactform .
docker run --env-file .env -p 8080:8080 fastapi-contactform
Once the container is running, the API can serve contact form requests from any connected frontend.
A Simple Frontend Example
Here is a minimal example of what a static site could send to this API:
<form id="contact-form">
<input type="text" id="name" placeholder="Your name" required />
<input type="email" id="email" placeholder="Your email" required />
<input type="text" id="subject" placeholder="Subject" required />
<textarea id="message" placeholder="Message" required></textarea>
<input type="hidden" id="turnstileToken" />
<button type="submit">Send</button>
</form>
<script>
document.getElementById("contact-form").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = {
name: document.getElementById("name").value,
email: document.getElementById("email").value,
subject: document.getElementById("subject").value,
message: document.getElementById("message").value,
turnstileToken: document.getElementById("turnstileToken").value
};
const response = await fetch("https://your-api-domain.com/contact", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
console.log(result);
});
</script>
In a real site, the Turnstile token would come from the Cloudflare Turnstile widget after the user completes verification.
What I Like About This Approach
This project is small, but it solves a real repeat problem.
Here is why I think this setup works well:
Reusable
I can connect many static sites to the same backend service.
Secure by default
The project includes bot verification, origin control, and startup config validation.
Flexible email delivery
I am not locked into one provider because the mail layer supports both Mailjet and SendGrid.
Easy deployment
Docker keeps the runtime consistent across environments.
Small enough to understand
The repository is intentionally tiny, with only a handful of files and a focused purpose. The public repo currently shows files like main.py, mail_services.py, Dockerfile, .env.example, and requirements.txt, with a short commit history.
Things I Learned While Building It
Every small backend teaches something useful.
Here are some lessons from this kind of project:
Keep the API focused
It is tempting to add dashboards, databases, and admin panels. But for a contact form service, the core job is simple: receive, verify, and forward.
Put validation close to the edge
Validating early with Pydantic reduces bad input before it spreads deeper into the system.
Make config errors fail early
Startup validation is much better than discovering missing secrets after users submit forms.
Abstract third-party services
Providers change. Pricing changes. Limits change. A thin abstraction makes future change less painful.
Security is part of the feature
A contact form is not complete until spam prevention and origin control are included.
Key Considerations
Before using this kind of API in production, I would pay attention to a few extra details:
- Rate limiting: The current project validates input and Turnstile, but adding rate limiting would strengthen protection further.
- Observability: Logging is already configured, and the app logs request flow and provider responses, which is a strong start.
- Secrets management: Production deployments should use a secure secret store rather than committing real values anywhere.
- Email deliverability: Make sure your sender domain is correctly configured for your chosen provider.
- Frontend UX: Show clear success and error states so users know whether the form was submitted.
- Provider fallback: In the future, it may be useful to support automatic fallback if one provider fails.
Recommendations
If you want to build something similar, here is the path I recommend:
- Start with one clean POST endpoint
- Validate all input with Pydantic
- Add server-side bot verification
- Restrict browser origins with CORS
- Abstract your email provider behind one interface
- Use environment variables for all secrets and provider settings
- Package the app with Docker
- Deploy once and reuse it across all static sites
I would also recommend keeping the first version intentionally small. A project like this becomes powerful because it is easy to understand and easy to reuse.
A Good Small Project
I like projects like this because they solve a very practical gap.
Static sites are excellent for content, landing pages, portfolios, and product pages. But the moment you need interaction, you need a tiny bridge to the dynamic world.
For me, this FastAPI contact form service became that bridge.
It gives my static sites a secure way to receive messages without turning every site into a full-stack application. And because it is containerized and provider-agnostic, it stays simple enough to maintain.
Conclusion
This project is a good example of a small backend doing one job well.
With FastAPI, Docker, Cloudflare Turnstile, and a mail provider like Mailjet or SendGrid, I was able to build a reusable contact form API that fits neatly behind my static websites. The repository shows exactly that setup: a FastAPI app, provider-based mail services, environment-driven config, and a simple Docker deployment path.
Sometimes the best projects are not huge.
Sometimes they are just the missing piece that makes everything else work.