Remote monitoring

Every Poste.io installation can expose an authenticated, read-only health and metrics API for external monitoring systems such as Uptime Kuma, Prometheus or Grafana. No extra port is opened and no monitoring agent is installed - everything is served by the administration web server you already use.

Monitoring is disabled by default, also after an upgrade of an existing installation.

Enable monitoring

In the administration go to System settings and open the Monitoring tab. Tick Enable remote monitoring API - a token is generated for you - and save.

The token is shown in the Token field, masked until you click the eye next to it. You can replace it any time with Regenerate token (the new one applies once you save; the previous one stops working immediately) or simply type your own.

The token gives read-only access to health and metrics information. It can not change any setting, can not read any email and does not work as an administration API token.

To switch monitoring off again, untick the checkbox and save - both endpoints then answer 404.

Authentication

The token is sent in an Authorization header. It is deliberately not accepted in the query string, where it would end up in proxy and access logs.

Authorization: Bearer YOUR_TOKEN
  • missing or invalid token → 401
  • monitoring disabled → 404

Health endpoint

$ curl \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  https://mail.example.com/admin/api/v1/monitoring/health

The answer is JSON with a stable, versioned schema. Fields may be added later, existing fields stay backward compatible.

{
  "schema": 1,
  "timestamp": "2026-08-18T12:00:00Z",
  "status": "ok",
  "summary": "all checks ok",
  "instance": {
    "id": "1fa17c9e-87cc-4b13-a76d-e18944e157a4",
    "version": "2.6.1",
    "uptime_seconds": 1838271
  },
  "services": {
    "smtp":       { "status": "ok" },
    "submission": { "status": "ok" },
    "imap":       { "status": "ok" },
    "http":       { "status": "ok" },
    "php-fpm":    { "status": "ok" },
    "redis":      { "status": "ok" },
    "clamav":     { "status": "ok" },
    "rspamd":     { "status": "ok" },
    "cron":       { "status": "ok" },
    "p0f":        { "status": "ok" }
  },
  "queue":    { "status": "ok", "messages": 3, "oldest_age_seconds": 42 },
  "storage":  { "status": "ok", "total_bytes": 536870912000, "used_bytes": 38912342342,
                "free_bytes": 497958569658, "used_percent": 7.25 },
  "certificate": { "status": "ok", "not_after": "2026-09-28T08:12:11Z",
                   "expires_in_seconds": 3514331 },
  "inventory": { "domains": 24, "mailboxes": 108 },
  "counters":  { "queued": 812, "delivered": 795, "bounced": 4, "quarantined": 61,
                 "discarded": 0, "valid": 903, "invalid": 217, "blacklisted": 4410, "limited": 0 }
}

Service state comes from the very same /healthcheck scripts the container's docker HEALTHCHECK runs, so docker ps and the monitoring API can never disagree about what "up" means. A service that is down does not break the response, it is reported instead, together with whatever the check itself said:

"imap": { "status": "critical", "message": "Dovecot is not running" }

/admin/api/v1/ is also reachable as /api/v1/; that shorter address answers with a redirect to the one above, so use the full path for monitoring tools that do not follow redirects.

HTTP status codes

200the report was collected - the verdict is in the body
401missing or invalid token
404monitoring is disabled

An authenticated request answers 200 however bad the verdict is, critical included. Monitoring tools discard the body of a non-2xx response and show nothing but "server error", which hides the very thing you need to see - so read status instead, and summary for a one-line reason:

"status": "critical",
"summary": "imap critical (Dovecot is not running); storage warning (88.4% used)"

What makes the status warning or critical

warningcritical
essential service (SMTP, submission, IMAP, HTTP, PHP-FPM, Redis)-down
supporting service (ClamAV, Rspamd, cron, p0f)down-
TLS certificateexpires in less than 14 daysexpires in less than 3 days
mail storage85 % used95 % used
oldest queued message7 days-

The number of queued messages is deliberately not a threshold - a normal queue length depends completely on the size of the installation.

The age of the oldest message is a weaker signal than it looks. Haraka retries a temporarily failed delivery on a doubling backoff twelve times, so a message legitimately stays queued for just over three days before it is turned into a bounce. The threshold therefore sits at a week, which means the retry ladder ran out and the message still was not bounced away - the self-cleaning is broken. Mail is still flowing in that situation, so it never escalates beyond a warning.

Prometheus

$ curl \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  https://mail.example.com/admin/api/v1/monitoring/metrics

The answer is Prometheus text exposition format. Cardinality is fixed and tiny - the only labels are a hardcoded service name, a hardcoded result name and the version.

# HELP poste_up Whether Poste.io is healthy (0 when overall status is critical).
# TYPE poste_up gauge
poste_up 1

# HELP poste_health_status Overall status as a number: 0 = ok, 1 = warning, 2 = critical.
# TYPE poste_health_status gauge
poste_health_status 0

# HELP poste_uptime_seconds Poste.io container uptime.
# TYPE poste_uptime_seconds gauge
poste_uptime_seconds 1838271

# HELP poste_service_up Whether a Poste.io service is healthy.
# TYPE poste_service_up gauge
poste_service_up{service="smtp"} 1
poste_service_up{service="submission"} 1
poste_service_up{service="imap"} 1
poste_service_up{service="http"} 1
poste_service_up{service="php-fpm"} 1
poste_service_up{service="redis"} 1
poste_service_up{service="clamav"} 1
poste_service_up{service="rspamd"} 1
poste_service_up{service="cron"} 1
poste_service_up{service="p0f"} 1

# HELP poste_mail_queue_messages Number of messages in the outbound queue.
# TYPE poste_mail_queue_messages gauge
poste_mail_queue_messages 3

# HELP poste_mail_queue_oldest_seconds Age of the oldest queued message, 0 when the queue is empty.
# TYPE poste_mail_queue_oldest_seconds gauge
poste_mail_queue_oldest_seconds 42

# HELP poste_storage_total_bytes Mail storage filesystem size.
# TYPE poste_storage_total_bytes gauge
poste_storage_total_bytes 536870912000

# HELP poste_storage_used_bytes Mail storage used bytes.
# TYPE poste_storage_used_bytes gauge
poste_storage_used_bytes 38912342342

# HELP poste_storage_free_bytes Mail storage free bytes.
# TYPE poste_storage_free_bytes gauge
poste_storage_free_bytes 497958569658

# HELP poste_storage_used_percent Mail storage usage in percent.
# TYPE poste_storage_used_percent gauge
poste_storage_used_percent 7.25

# HELP poste_certificate_expiry_timestamp_seconds TLS certificate expiration Unix timestamp.
# TYPE poste_certificate_expiry_timestamp_seconds gauge
poste_certificate_expiry_timestamp_seconds 1790583131

# HELP poste_domains_total Number of configured domains.
# TYPE poste_domains_total gauge
poste_domains_total 24

# HELP poste_mailboxes_total Number of mailboxes.
# TYPE poste_mailboxes_total gauge
poste_mailboxes_total 108

# HELP poste_messages_today Messages processed today by result, resets at midnight UTC.
# TYPE poste_messages_today gauge
poste_messages_today{result="queued"} 812
poste_messages_today{result="delivered"} 795
poste_messages_today{result="bounced"} 4
poste_messages_today{result="quarantined"} 61
poste_messages_today{result="discarded"} 0

# HELP poste_connections_today Inbound SMTP connections today by result, resets at midnight UTC.
# TYPE poste_connections_today gauge
poste_connections_today{result="valid"} 903
poste_connections_today{result="invalid"} 217
poste_connections_today{result="blacklisted"} 4410
poste_connections_today{result="limited"} 0

# HELP poste_build_info Poste.io build information.
# TYPE poste_build_info gauge
poste_build_info{version="2.6.1"} 1

poste_messages_today and poste_connections_today are gauges, not counters. Poste.io keeps these aggregates per day and they reset at midnight UTC, so a monotonically increasing _total counter would be misleading. Use them as daily values, not with rate().

An example scrape configuration:

scrape_configs:
  - job_name: poste
    scrape_interval: 60s
    scheme: https
    metrics_path: /admin/api/v1/monitoring/metrics
    authorization:
      type: Bearer
      credentials: YOUR_TOKEN
    static_configs:
      - targets: ['mail.example.com']

Uptime Kuma

Create an HTTP(s) - Json Query monitor (or a plain HTTP(s) monitor) pointing at the health endpoint, and add the token as a request header:

{ "Authorization": "Bearer YOUR_TOKEN" }

The HTTP status code only tells you the endpoint answered, so point the monitor at the JSON instead. In Uptime Kuma pick the HTTP(s) - Json Query type, query $.status and expect ok; the failure notification then names the value it got. Query $.summary to see which check is at fault.

For more detailed monitoring query the rest of the JSON, for example queue.oldest_age_seconds, storage.used_percent or certificate.expires_in_seconds.

Outbound heartbeat

Instead of (or in addition to) being polled, Poste.io can push its status to a URL you configure. On the same Monitoring tab tick Enable outbound monitoring heartbeat, fill in a https:// URL and an interval (60 seconds minimum, 60 seconds default).

The heartbeat is executed from the existing periodic job mechanism inside the container - no extra daemon is started. It sends:

POST <your URL>
Content-Type: application/json

{
  "schema": 1,
  "timestamp": "2026-08-18T12:00:00Z",
  "instance_id": "1fa17c9e-87cc-4b13-a76d-e18944e157a4",
  "version": "2.6.1",
  "status": "ok",
  "services": { "smtp": true, "submission": true, "imap": true, "http": true, ... },
  "queue": { "messages": 3, "oldest_age_seconds": 42 },
  "storage": { "used_percent": 7.25, "free_bytes": 497958569658 },
  "certificate": { "expires_in_seconds": 3514331 }
}

The payload is an explicit whitelist: no domain names, no mailbox names, no addresses, no message metadata, no credentials and never the monitoring token itself. The heartbeat uses short timeouts (2 s to connect, 5 s in total), does not follow redirects, and a failure is logged at most once per hour and can never affect mail delivery.

Collecting the status takes a variable amount of time, so the request is held until a fixed 10 s after the measurement started. The spacing between two heartbeats is then the interval you configured and nothing else, which matters when the receiver is a dead man's switch with a grace period. A collection that does not finish within those 10 s is not waited out - it is sent straight away, marked late= in /data/log/admin-app/monitoring-heartbeat.log, and logged, because taking that long is itself a statement about the installation.

If a heartbeat arrives later than it should, run it once by hand with --trace:

sudo -u mail /opt/admin/bin/console monitoring:heartbeat --env=prod --force --trace

Every stage then prints its own line - starting PHP, booting the command, reading the state, collecting, the hold, and DNS, connect and response of the POST itself - each with its offset from the start of the process and the time since the previous stage. Add --trace to the line in /etc/cron.d/mailserver-checks to capture the same breakdown for every run while you are chasing something intermittent.

Installation identifier

Each installation generates a random identifier once and keeps it in /data/instance-id, so it survives restarts and upgrades. It is derived from random bytes only - never from your hostname, domains, IP address or hardware - and exists purely so a monitoring system can recognise the same installation over time.

Privacy

The monitoring API and the Prometheus metrics never contain email addresses, mailbox names, aliases, domain names, senders, recipients, subjects, message ids or message contents. Only aggregate numbers are exposed. The heartbeat URL you configure is stored in the server configuration but is never returned by the monitoring API.