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
-
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. SetPOSTKEEP_PUBLIC_URLto the origin only, such ashttps://mail.example.com, with no path. -
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:
docker run --rm postkeep/postkeep generate-key # or: postkeep generate-key, or: openssl rand -hex 32Pass it as
POSTKEEP_MASTER_KEY, or put it in a file and setPOSTKEEP_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). -
Persistent storage. By default the server uses a SQLite file (
/data/postkeep.dbin the image), so mount a volume at/data. Upgrading from 0.2.x with the default SQLite path: 0.3.0 looks forpostkeep.db, so rename your existing database file topostkeep.db(in the image,/data/postkeep.db) or pointPOSTKEEP_DATABASE_URLat it before starting. Account-page users sign in again once. If 0.3.0 has already started and created an emptypostkeep.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=requireencrypts without that check. If the provider uses a private CA, also addsslrootcert=/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 setPOSTKEEP_DATABASE_IDLE_TIMEOUT=300andPOSTKEEP_DATABASE_MAX_LIFETIME=600. These are examples, not defaults. Migrations run at start. Behind a transaction pooler, run them on a direct connection instead: setPOSTKEEP_DATABASE_MIGRATE_URLto it, runpostkeep migrateas a release command (it exits 0 when there is nothing to do), and setPOSTKEEP_DATABASE_MIGRATE_ON_START=falseso 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:
cd deploy/compose
cp .env.example .env && chmod 600 .env
# edit .env: PUBLIC_URL, MASTER_KEY, ALLOWED_EMAILS, DOMAIN
docker compose --profile caddy up -dThe 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:
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 servedeploy/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
Hostheader. The MCP endpoint rejects unknown hosts to prevent DNS rebinding. If your proxy rewritesHost, add the value it sends toPOSTKEEP_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):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:
[http_service]
auto_stop_machines = false
min_machines_running = 1Mail 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 /healthzreturns200 okwhen the database answers. The image’sHEALTHCHECKrunspostkeep healthcheck, which needs no shell or curl.GET /readyzreturns200 readywhen the database answers, has every migration this version needs, and the server is not shutting down. Otherwise it returns503with the reason:draining,database unavailableormigrations pending. An answer is reused for up to a second. Point load balancer and readiness checks at/readyzand 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,
/readyzanswers503 drainingat once. The server keeps serving forPOSTKEEP_SHUTDOWN_DRAINseconds (default0), 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 asterminationGracePeriodSecondson Kubernetes orkill_timeouton Fly.io. - Logs go to stderr. Set
POSTKEEP_LOG_FORMAT=jsonfor 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 (
/datavolume or PostgreSQL) and the master key, stored separately. - To disconnect one person, they can use
/accounton 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_EMAILSlists 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_ACCOUNTnames a service account used only by the push subscription.