Skip to main content

Architecture

Posta ships as a single binary that runs in two modes. posta serves the HTTP API, the dashboard, and the SMTP listeners; posta worker consumes the job queue. Both are stateless — all state lives in PostgreSQL, Redis, and object storage — so you can run as many workers as your send volume needs.

The two processes

posta — the server

Accepts work and answers questions. It never sends mail itself.

ListenerDefault portPurpose
HTTP9000REST API, dashboard, web view, tracking pixels, form ingest
Inbound SMTP2525Receives mail delivered to your MX records
SMTP relay2526Accepts mail from an existing SMTP client

Everything that takes time — delivery, retries, campaign batches, inbound parsing, webhook fan-out — is written to PostgreSQL and enqueued in Redis. The request returns as soon as the job is durable, which is why a send is answered in milliseconds regardless of how slow the receiving mail server is.

posta worker — the consumer

Pulls jobs off the queue and does the slow work: opening SMTP connections, retrying failures, walking campaign recipient lists, parsing inbound messages, delivering webhooks, and running scheduled jobs such as retention cleanup and daily reports.

Workers are interchangeable. Adding one increases throughput; losing one leaves its in-flight jobs to be retried by another.

Running the worker

An embedded worker (POSTA_EMBEDDED_WORKER=true) is fine for evaluation and small installs — one container, nothing else to run. Production deployments should split them, so sending, retries, campaigns, and scheduled jobs scale independently of request traffic and a burst of API calls cannot starve delivery.

See examples/docker-compose-full.yml for a two-process deployment.

warning

Run at least one worker. With no worker and no embedded worker, the API accepts sends and returns queued — and nothing ever delivers them. Everything looks healthy while the queue grows.

Health and metrics

A dedicated worker serves the same probes as the server, on the same port (POSTA_PORT, default 9000). Server and worker run as separate containers, so one port and one health check command cover both.

EndpointMeaning
/healthzThe process is alive. Checks nothing else on purpose — a liveness probe that fails during a database blip gets the worker restarted, which loses in-flight work and does not fix the database.
/readyzDependencies are reachable and the worker is consuming. Returns 503 otherwise, so an orchestrator stops routing to a worker that has stopped processing.
/metricsPrometheus exposition for this process.

The image ships a health check that covers both roles:

healthcheck:
test: ["CMD", "posta-healthcheck"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3

Set POSTA_WORKER_HEALTH_ENABLED=false to disable the listener, or POSTA_WORKER_HEALTH_PORT to move it.

Shared state

StoreHoldsRequired
PostgreSQLEmails, templates, contacts, subscribers, campaigns, domains, logs, audit trailYes
RedisAsynq queues, the scheduler, caches, rate-limit countersYes
Object storageAttachments and raw inbound messagesNo

Object storage is optional. With POSTA_BLOB_PROVIDER unset, attachments and raw inbound messages stay in the database.

caution

Any multi-node deployment should use s3. A local fs path is not shared between the server and its workers, so a worker on another host cannot read an attachment the server wrote.

Queues

Jobs are split across three Asynq queues so that a large campaign cannot delay a password reset:

QueueWeightCarries
transactional6API and relayed sends, inbound parsing and forwarding, form messages
bulk3Campaign starts, campaign batches, and the individual campaign sends they produce
low1Per-workspace daily reports

The weights are relative, not exclusive: a worker with nothing transactional to do will happily process bulk. Scheduled maintenance such as retention cleanup runs on the cron scheduler in-process rather than through a queue.

Stack

  • Backend — Go, using the Okapi web framework
  • Frontend — Vue 3 + Vite, embedded into the binary
  • Database — PostgreSQL
  • Queue — Redis with Asynq
  • Metrics — Prometheus

Next steps