Skip to content

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.

  1. Create a working directory.

    Terminal window
    mkdir posthorn-test && cd posthorn-test
    • Directoryposthorn-test/
      • docker-compose.yml
      • posthorn.toml
      • .env
  2. 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 = 5
    interval = "1m"

    Replace you@example.com with your inbox and noreply@example.com with an address on your Postmark-verified sending domain.

  3. Write .env.

    Terminal window
    POSTMARK_API_KEY=your-postmark-server-token-here
  4. Write docker-compose.yml.

    services:
    posthorn:
    image: ghcr.io/craigmccaskill/posthorn:latest
    restart: unless-stopped
    volumes:
    - ./posthorn.toml:/etc/posthorn/config.toml:ro
    env_file: .env
    ports:
    - "8080:8080"
  5. Start the container.

    Terminal window
    docker compose up -d
    docker compose logs -f posthorn

    You 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"}
  6. 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 OK with 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_id lets 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.

  7. Check your inbox. The email should arrive within seconds. Subject line will be Contact from Test User. Body will be the rendered template.

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
}