Contact form on a static site
The canonical Posthorn use case. You have a static site (Astro, Jekyll, Hugo, Eleventy, plain HTML — doesn’t matter) and you want a contact form that sends a real email when someone submits it. No JavaScript framework, no SaaS form service, no $5/month subscription.
At the end: a /contact page on your site with a working form. Submissions arrive in your inbox within seconds. Honeypot + Origin-check defenses are on. Rate limit is set so you don’t get hammered.
Prerequisites
Section titled “Prerequisites”- A Postmark account with a verified sender signature on your sending domain (SPF + DKIM verified in Postmark; DMARC recommended but not required for a first send — see DNS)
- Postmark server token (per-server, found under your server’s API Tokens tab — not the account-level token)
- A Docker host you control (homelab, VPS, anywhere)
- A reverse proxy in front of your static site already (Caddy, nginx, Traefik, Cloudflare — any will work)
Walkthrough
Section titled “Walkthrough”-
Create a directory for the Posthorn deployment.
Terminal window mkdir -p posthorn && cd posthornDirectoryposthorn/
- docker-compose.yml
- posthorn.toml
- .env
-
Write
posthorn.toml.[[endpoints]]path = "/api/contact"to = ["you@yourdomain.com"]from = "Contact Form <noreply@yourdomain.com>"reply_to_email_field = "email"honeypot = "_gotcha"allowed_origins = ["https://yourdomain.com", "https://www.yourdomain.com"]required = ["name", "email", "message"]subject = "Contact from {{.name}}"body = """From: {{.name}} <{{.email}}>{{.message}}"""redirect_success = "/thank-you/"redirect_error = "/contact/?error=1"[endpoints.transport]type = "postmark"[endpoints.transport.settings]api_key = "${env.POSTMARK_API_KEY}"[endpoints.rate_limit]count = 5interval = "1m"Replace
yourdomain.comwith your actual domain, andyou@yourdomain.comwith where you want the contact mail delivered. -
Write
.env.Terminal window POSTMARK_API_KEY=your-server-token-hereAdd
.envto your.gitignoreif this directory is in a repo. -
Write
docker-compose.yml.services:posthorn:image: ghcr.io/craigmccaskill/posthorn:latestrestart: unless-stoppedvolumes:- ./posthorn.toml:/etc/posthorn/config.toml:roenv_file: .envports:- "127.0.0.1:8080:8080" # loopback only; reverse-proxy from your front doorThe
127.0.0.1:prefix is important — Posthorn doesn’t terminate TLS, so it should never be reachable from the public internet directly. -
Start the container.
Terminal window docker compose up -ddocker compose logs -f posthornExpect a startup log line like:
{"time":"2026-05-17T20:00:00Z","level":"INFO","msg":"http ingress listening","addr":":8080"} -
Wire
/api/contactthrough your reverse proxy.For Caddy:
yourdomain.com {handle /api/contact {reverse_proxy 127.0.0.1:8080}handle {root * /var/www/yourdomain.comfile_server}}For nginx:
location = /api/contact {proxy_pass http://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;proxy_set_header Origin $http_origin;proxy_set_header Referer $http_referer;}See Reverse proxy for Traefik and other front doors.
-
Add the form to your contact page.
<form method="POST" action="/api/contact"><label>Name<input name="name" type="text" required></label><label>Email<input name="email" type="email" required></label><label>Message<textarea name="message" rows="6" required></textarea></label><!-- Honeypot: invisible to humans, visible to drive-by bots --><input type="text" name="_gotcha" tabindex="-1" autocomplete="off"style="position:absolute;left:-9999px" aria-hidden="true"><button type="submit">Send</button></form>Add a
/thank-you/page so the post-submit redirect lands somewhere friendly. Add an error state to your contact page that triggers when the URL has?error=1. -
Send a test submission with curl.
Terminal window curl -i -X POST https://yourdomain.com/api/contact \-H "Content-Type: application/x-www-form-urlencoded" \-d "name=Test User" \-d "email=test@example.com" \-d "message=Hello from curl"Expect:
HTTP/2 200content-type: application/json; charset=utf-8{"status":"ok","submission_id":"7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f"}Check your inbox. The message should arrive within seconds.
Common gotchas
Section titled “Common gotchas”| Symptom | Likely cause | Fix |
|---|---|---|
HTTP 403 forbidden from a real form submission | allowed_origins doesn’t include the variant of your domain the form is on (e.g., you listed yourdomain.com but the form is on www.yourdomain.com) | Add both variants to allowed_origins |
HTTP 422 validation failed with {"name":"required"} etc. | The form field names don’t match the required list in TOML | Make sure each <input name="..."> matches an entry in required exactly |
HTTP 200 but no email arrives | DNS — your sending domain doesn’t have SPF/DKIM/DMARC set up, so Postmark accepts the submission but the recipient drops it as spam | See DNS and verify via the recipient’s “show original” header view |
Real users keep hitting HTTP 429 rate limit | Multiple users behind the same Network Address Translation (NAT) gateway, or your reverse proxy isn’t forwarding client IPs | Add trusted_proxies = ["<your-proxy-CIDR>"] (Classless Inter-Domain Routing range) to the endpoint and bump count up |
| Honeypot doesn’t fire on test bots | The honeypot field isn’t actually rendered in the HTML | The honeypot must be in the HTML for bots to fill it in — hidden via CSS, but present |
| Email body lands in spam folder | DKIM / SPF / DMARC misconfiguration on the sending domain | First-time gotcha for almost every new deployment — see DNS |
Where to go next
Section titled “Where to go next”- Configuration reference — every field documented
- Spam protection — tune the defenses for your traffic shape
- Multi-form site — add a feedback form, a careers form, a newsletter signup alongside the contact form
- DNS — SPF, DKIM, DMARC setup if you haven’t already