Install

Deploying postkeep

postkeep is one binary, and one container image for linux/amd64 and linux/arm64. The Docker Hub image is postkeep/postkeep; the Dockerfile builds the same image from source. It needs no particular platform, and everything is configured with environment variables.

What every deployment needs

  1. A public HTTPS URL. MCP clients and your browser must reach the server, and OAuth requires HTTPS outside localhost. Terminate TLS in a reverse proxy (Caddy, nginx, Traefik) or use a host that provides it. Set POSTKEEP_PUBLIC_URL to the origin only, such as https://mail.example.com, with no path.

  2. A master key. It encrypts stored app passwords, Microsoft and Google tokens, webhook secrets and stored webhook events (AES-256-GCM). Generate one and keep a backup:

    sh
    docker run --rm postkeep/postkeep generate-key
    # or: postkeep generate-key, or: openssl rand -hex 32

    Pass it as POSTKEEP_MASTER_KEY, or put it in a file and set POSTKEEP_MASTER_KEY_FILE (works well with Docker, Kubernetes and systemd secrets). If the key is lost or changed, none of these can be decrypted: every user must connect again, and webhook secrets and queued events are lost. To change the key without that, rotate it (see configuration).

  3. Persistent storage. By default the server uses a SQLite file (/data/postkeep.db in the image), so mount a volume at /data. Upgrading from 0.2.x with the default SQLite path: 0.3.0 looks for postkeep.db, so rename your existing database file to postkeep.db (in the image, /data/postkeep.db) or point POSTKEEP_DATABASE_URL at it before starting. Account-page users sign in again once. If 0.3.0 has already started and created an empty postkeep.db, stop it and replace that file with your old one. For several replicas or hosts without disks, use PostgreSQL: POSTKEEP_DATABASE_URL=postgres://user:password@host/db. When the database is remote, add ?sslmode=verify-full. That checks the server certificate and host name against the webpki roots compiled into the binary. sslmode=require encrypts without that check. If the provider uses a private CA, also add sslrootcert=/path/ca.pem (a PEM file the process can read). The schema is created and migrated automatically at startup. Each instance opens at most 10 database connections (POSTKEEP_DATABASE_MAX_CONNECTIONS). Behind a proxy or pooler that closes connections, recycle them sooner. For example, Fly Managed Postgres recommends a 300-second idle timeout and a 600-second lifetime (Fly), so set POSTKEEP_DATABASE_IDLE_TIMEOUT=300 and POSTKEEP_DATABASE_MAX_LIFETIME=600. These are examples, not defaults. Migrations run at start. Behind a transaction pooler, run them on a direct connection instead: set POSTKEEP_DATABASE_MIGRATE_URL to it, run postkeep migrate as a release command (it exits 0 when there is nothing to do), and set POSTKEEP_DATABASE_MIGRATE_ON_START=false so the server refuses to start on a schema it has not seen.

Recommended: set POSTKEEP_ALLOWED_EMAILS to the addresses that may connect. Without it, anyone who can reach the server can connect any mailbox it accepts: iCloud, Fastmail and Yahoo by default, Outlook or Gmail when those OAuth apps are set up, and any IMAP server you configured.

The full list of variables is in configuration.md and in postkeep serve --help.

Docker Compose

deploy/compose contains a ready-to-use setup:

sh
cd deploy/compose
cp .env.example .env && chmod 600 .env
# edit .env: PUBLIC_URL, MASTER_KEY, ALLOWED_EMAILS, DOMAIN
docker compose --profile caddy up -d

The caddy profile adds Caddy, which obtains a certificate for DOMAIN automatically (ports 80 and 443 must be reachable, and DNS must point at the machine). Without the profile, the app listens on 127.0.0.1:8080 for your own reverse proxy.

Upgrade with docker compose pull && docker compose up -d. Pin IMAGE_TAG to a version (for example 0.3.2) for predictable upgrades.

Plain binary

Build it with cargo build --release (Rust 1.94 or newer), or take it from the container image. Then:

sh
export POSTKEEP_PUBLIC_URL=https://mail.example.com
export POSTKEEP_MASTER_KEY_FILE=/etc/postkeep/master.key
export POSTKEEP_DATABASE_URL=sqlite:///var/lib/postkeep/postkeep.db
postkeep serve

deploy/systemd/postkeep.service is a hardened systemd unit. It runs as a dynamic user, passes the master key as a systemd credential and keeps the database in /var/lib/postkeep.

Reverse proxy notes

  • Forward the original Host header. The MCP endpoint rejects unknown hosts to prevent DNS rebinding. If your proxy rewrites Host, add the value it sends to POSTKEEP_ALLOWED_HOSTS.

  • Pass the client IP and set POSTKEEP_CLIENT_IP_HEADER, so sign-in backoff and rate limits apply per client rather than to the proxy (see configuration.md).

  • Allow long requests. A tool call waits for the mail provider and can take tens of seconds (for example proxy_read_timeout 120s; in nginx).

  • nginx example (with POSTKEEP_CLIENT_IP_HEADER=X-Real-IP):

    nginx
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_http_version 1.1;
        proxy_read_timeout 120s;
    }

Hosting platforms

Any platform that runs a container and gives it an HTTPS URL works. Set the environment variables above and give it a volume or PostgreSQL.

Platform type Notes
A VPS or home server Docker Compose with the Caddy profile, or the systemd unit
Container PaaS with volumes (Fly.io, Railway, Render, Koyeb, …) Mount a volume at /data and run a single instance, or use PostgreSQL. deploy/fly/fly.toml is a worked example for Fly.io
Serverless containers without disks (Cloud Run, Azure Container Apps, …) Use PostgreSQL; the server keeps no other state
Kubernetes A Deployment with the image, a Secret for the key (mounted as a file plus POSTKEEP_MASTER_KEY_FILE), a PVC for SQLite (one replica) or PostgreSQL (several), and an Ingress with TLS. Point the liveness probe at /healthz and the readiness probe at /readyz, and set POSTKEEP_SHUTDOWN_DRAIN (for example 5) so a pod leaves the Service before it stops accepting connections

Scale-to-zero is fine. Tokens survive restarts because they live in the database. The exception is new-mail webhooks, below.

New-mail webhooks

Webhook watchers hold an IMAP connection open (iCloud, Fastmail, Yahoo) or keep a Microsoft Graph subscription and a Gmail watch renewed (Outlook, Gmail), and only run while the server does. Outlook and Gmail also call the server at /push/microsoft, /push/microsoft/lifecycle and /push/google; those paths must be reachable from the internet over HTTPS. Don’t put them behind authentication at the proxy: postkeep checks each request itself (see webhooks). If anyone uses webhooks, keep one instance running and don’t let the platform stop it when no HTTP traffic arrives. On Fly.io, set in fly.toml:

toml
[http_service]
  auto_stop_machines = false
  min_machines_running = 1

Mail that arrives while the server is down is reported when it starts again, within these limits. An IMAP backlog is reported oldest first as several events of at most 50 messages each. Each Outlook event covers at most 50 messages, and a larger burst is several events in a row, reported without waiting for another notification. A Gmail history catch-up can cover more than 50 messages in one event. If that history has expired or paging is cut off, the resync lists at most one page of 50 Inbox messages and then moves to the current history id, so older messages past that page are not reported. Run a single instance with webhooks enabled: several instances would each deliver the same events. Set POSTKEEP_WEBHOOKS=false to turn the feature off entirely.

Health and operations

  • GET /healthz returns 200 ok when the database answers. The image’s HEALTHCHECK runs postkeep healthcheck, which needs no shell or curl.
  • GET /readyz returns 200 ready when the database answers, has every migration this version needs, and the server is not shutting down. Otherwise it returns 503 with the reason: draining, database unavailable or migrations pending. An answer is reused for up to a second. Point load balancer and readiness checks at /readyz and liveness checks at /healthz, so a server that is shutting down or whose database is behind stops getting requests but is not restarted.
  • On SIGINT or SIGTERM, /readyz answers 503 draining at once. The server keeps serving for POSTKEEP_SHUTDOWN_DRAIN seconds (default 0), or until a second signal, so load balancers can stop sending it requests. Then it stops accepting connections, gives open requests up to 10 seconds, gives webhook watchers up to 5 seconds to log out, and exits. Leave room for all of that in the platform’s stop timeout, such as terminationGracePeriodSeconds on Kubernetes or kill_timeout on Fly.io.
  • Logs go to stderr. Set POSTKEEP_LOG_FORMAT=json for log collectors. Passwords, tokens and message contents are kept out of the logs (threat model).
  • Expired authorization codes and tokens are purged every 10 minutes.
  • Backups: back up the database (/data volume or PostgreSQL) and the master key, stored separately.
  • To disconnect one person, they can use /account on your server to revoke every app or delete their data. That blocks further MCP requests at once. To cut off mailbox access, revoke the credential at the provider: delete the app password (for iCloud Mail, at account.apple.com; Fastmail and Yahoo have their own app-password settings). Microsoft doesn’t let apps remove their own access, so remove postkeep at account.live.com/consent/Manage (personal accounts) or myapps.microsoft.com (work or school). For Google, remove postkeep at myaccount.google.com/permissions; deleting the account on the account page also tries to revoke that access at Google. Revoking the credential at the provider stops the server’s mailbox access once a cached provider access token expires or an open IMAP session ends. To cut off everyone, stop the server and delete the database. Changing the master key also makes stored app passwords, provider tokens, webhook secrets and queued events unusable.

Security checklist

  • Served only over HTTPS; the app port is not exposed directly.
  • POSTKEEP_ALLOWED_EMAILS lists the addresses that may connect.
  • The master key is stored as a secret and backed up separately from the database.
  • The image is pinned to a version and upgraded deliberately.
  • Microsoft and Google client secrets are stored as secrets, and the Microsoft secret’s expiry date is in your calendar.
  • For Gmail push, POSTKEEP_GOOGLE_PUBSUB_SERVICE_ACCOUNT names a service account used only by the push subscription.