Quick start
This walks through standing up a working contact form on Docker against a real transactional-mail account. It assumes you’ve already followed Installation and have a provider API key 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 JSON log lines like:
{"time":"2026-05-16T20:00:00Z","level":"INFO","msg":"posthorn starting","version":"v1.0.0","listen":":8080","config":"/etc/posthorn/config.toml","endpoints":1}{"time":"2026-05-16T20:00:00Z","level":"INFO","msg":"endpoint registered","path":"/api/contact","transport":"postmark","recipients":1}{"time":"2026-05-16T20:00:00Z","level":"INFO","msg":"http ingress 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"}Back in the terminal where you ran
docker compose logs -f posthorn(step 5), a structured log line appears:{"time": "2026-05-16T20:01:23Z","level": "INFO","msg": "submission_sent","submission_id": "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f","endpoint": "/api/contact","transport": "postmark","latency_ms": 312,"transport_message_id": "0a1b2c3d-postmark-id"}transport_message_idlets you pivot from Posthorn’s log straight to the message in the Postmark dashboard. See Reading the logs for how to filter, ship, and recover failed submissions from the log stream. -
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