Quick start
This walks through standing up a working contact form on Docker against a real Postmark account. It assumes you’ve already followed Installation and have a Postmark server token in hand.
-
Create a working directory.
Terminal window mkdir posthorn-test && cd posthorn-testDirectoryposthorn-test/
- docker-compose.yml
- posthorn.toml
- .env
-
Write
posthorn.toml.[[endpoints]]path = "/api/contact"to = ["you@example.com"]from = "Contact Form <noreply@example.com>"honeypot = "_gotcha"required = ["name", "email", "message"]subject = "Contact from {{.name}}"body = """From: {{.name}} <{{.email}}>{{.message}}"""[endpoints.transport]type = "postmark"[endpoints.transport.settings]api_key = "${env.POSTMARK_API_KEY}"[endpoints.rate_limit]count = 5interval = "1m"Replace
you@example.comwith your inbox andnoreply@example.comwith an address on your Postmark-verified sending domain. -
Write
.env.Terminal window POSTMARK_API_KEY=your-postmark-server-token-here -
Write
docker-compose.yml.services:posthorn:image: ghcr.io/craigmccaskill/posthorn:latestrestart: unless-stoppedvolumes:- ./posthorn.toml:/etc/posthorn/config.toml:roenv_file: .envports:- "8080:8080" -
Start the container.
Terminal window docker compose up -ddocker compose logs -f posthornYou should see a JSON log line like:
{"time":"2026-05-14T20:00:00Z","level":"INFO","msg":"listening","addr":":8080"} -
Submit a test form.
Terminal window curl -i -X POST http://localhost:8080/api/contact \-H "Content-Type: application/x-www-form-urlencoded" \-d "name=Test User" \-d "email=test@example.com" \-d "message=hello from curl"You should receive
HTTP/1.1 200 OKwith a JSON body:{"status":"ok","submission_id":"7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f"}And a structured log line on the server:
{"time": "2026-05-14T20:01:23Z","level": "INFO","msg": "submission_sent","submission_id": "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f","endpoint": "/api/contact","transport": "postmark","latency_ms": 312} -
Check your inbox. The email should arrive within seconds. Subject line will be
Contact from Test User. Body will be the rendered template.
Wiring it into a real form
Section titled “Wiring it into a real form”The form action POSTs to /api/contact on Posthorn. For a static site behind Caddy:
<form method="POST" action="/api/contact"> <label>Name <input name="name" required></label> <label>Email <input name="email" type="email" required></label> <label>Message <textarea name="message" required></textarea></label> <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px"> <button type="submit">Send</button></form>The _gotcha field is the honeypot — visible to bots, invisible to humans. Any non-empty value silently 200s the request without sending mail.
For a Caddy reverse proxy in front of Posthorn:
example.com { reverse_proxy /api/contact* posthorn:8080 file_server}Common next steps
Section titled “Common next steps”- Lock down origins with
allowed_originsso only your domain can submit - Set up SPF, DKIM, and DMARC on your sending domain (skip and your mail goes to spam)
- Add a custom success/error redirect for non-JS forms
- Browse the full configuration reference