Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running a mail server

A SithBit deployment is at most six services, all built from this workspace:

ServiceBinaryRoleNeeded when
sithbitdmail-spoolerSMTP MX + submission, IMAP, POP, the spooler workers — and optionally the embedded IPFS node — in one processalways (the embedded IPFS node only with [ipfs] kind = "embedded" — a fleet points at sithbit-ipfsd or a pinning service instead)
account-apiaccount-apiwallet-challenge login → JWT; mail passwords, timezone, DND schedulesusers manage their accounts
mail-grpcmail-grpcgRPC gateway to the Solana programs (postage checks, SendMail, aliases)mail should reach the chain
domain-sithbitdomain-sithbitDNS-based domain verification and on-chain domain authorizationyou operate the domain registry
sithbit-ipfsdsithbit-ipfsdthe self-hosted IPFS node as a standalone daemon: HTTP pin API + optional public swarma fleet shares one node via [ipfs] kind = "remote" (a single sithbitd can embed the node instead)
sithbit-gatewaysithbit-gatewayread-only IPFS HTTP path gateway over the same block/pin bucket (deserialized, raw, CAR)pinned mail blobs should be fetchable/verifiable over plain HTTP

Everything else is an external dependency you point config at: a Solana RPC endpoint, TLS certificates, and DNS records (see DNS setup). Mail bodies pin to IPFS through the embedded node, the shared sithbit-ipfsd, or a third-party service (Filebase/Pinata) — the [ipfs] reference covers the selection.

The sections below climb from a zero-config dev run to the production compose topology. Each rung is runnable on its own; pick the highest one you need.

Note: if you notice smtp-server, imap-server, or pop-server binaries elsewhere in the workspace, see Appendix: Development and pilot servers — they’re dev/pilot artifacts, not part of this deployment.

Your stack, not a vendor’s

SithBit is a protocol, and the server is built to be run anywhere — a laptop, a single VM, or a cloud fleet — with no tie to any one platform, cloud, or storage product. That portability isn’t a promise bolted on; it’s how the code is structured. Every place the server touches infrastructure sits behind a trait, with more than one backend already implemented, so changing where your data lives is a config edit, not a rewrite:

  • Storage is one storage kernel behind swappable [store] backends: sqlite (a single file — the zero-config default), postgres, aws (DynamoDB + SQS), azure, turso, or cloudflare. Start on SQLite on your laptop and move to a cloud store later without touching application code. (Google Cloud needs no backend of its own — postgres against Cloud SQL is the GCP shape; see Hosting on Google Cloud.)
  • Mail bodies pin through a blob store that is either local disk or a cloud object store — S3-compatible (AWS S3, Google Cloud Storage via its S3-interop endpoint, MinIO, and the like) or Azure — and reach IPFS through the embedded node, a shared sithbit-ipfsd, or a third-party pinning service (Filebase, Pinata) — your choice, same trait.
  • Secrets (signing keypairs) load from a plain file or a cloud secret manager — Azure Key Vault, AWS Secrets Manager, or Google Secret Manager — through one key source, so no key material has to live in your config. (Cloudflare is the deliberate omission: its secrets products are write-only over the API, so nothing can fetch a value back out.)
  • The chain is any Solana RPC endpoint — your own validator, a provider, or a public cluster.

Infrastructure-as-code ships for all three major clouds — Terraform for AWS and Google Cloud, Bicep for Azure, under iac/ — because the point is that none of them is required. Nothing here reaches for a proprietary API you can’t swap out; the backends are peers behind a trait, and adding another is a matter of implementing that trait. See Scaling out for how the same seams take a single-process dev stack to a horizontally-scaled fleet.

Bare binaries (zero config)

Every binary runs with no config file at all and lands on loopback dev ports — see the configuration reference for the defaults and how to override them:

cargo run -p mail-spooler --bin sithbitd   # SMTP :2525, IMAP :1430, POP :1100
cargo run -p account-api                   # HTTP :8180
cargo run -p domain-sithbit                # HTTP :8181
cargo run -p mail-grpc                     # gRPC :50051 (reads .env)
cargo run -p ipfs-daemon                   # HTTP :8182 (pin API)
cargo run -p ipfs-gateway                  # HTTP :8183 (read-only gateway)

Without a [grpc] + [ipfs] section, sithbitd disables the chain pipeline: mail is accepted, delivered to mailboxes, and readable over IMAP/POP, but delivered copies stay in chain state received. That is the expected dev shape, not an error.

For long-running processes, build with the max-performance profile instead of cargo run’s dev profile:

cargo build --profile server -p mail-spooler -p account-api -p mail-grpc -p domain-sithbit -p ipfs-daemon -p ipfs-gateway
ls target/server/   # sithbitd, account-api, mail-grpc, domain-sithbit, sithbit-ipfsd, sithbit-gateway

Slim-build features

Two feature families let a binary compile out the cloud SDKs it never uses. The first — and largest — is the storage backends: cargo features of mail_store, forwarded under the same names by every store-consuming binary (mail-spooler, account-api, ipfs-daemon, ipfs-gateway, mail-console; sithbit-migrate always builds them all — it exists to move data between backends):

  • Default = sqlite — a plain cargo build compiles only the SQLite backend: the zero-config dev shape, with none of the cloud SDKs in the dependency tree.
  • --features <crate>/all — every backend (SQLite, Postgres, DynamoDB+SQS, Azure Tables/Queues, Turso/libSQL, Cloudflare) plus the S3 blob store. This is what the container images build: one image carries all backends, and the compose files pick one at runtime via [store] kind — the aws/azure/split stacks all run the same image.
  • Individual features (--features postgres, aws, azure, turso, cloudflare, s3-blobs) exist for slimmer custom builds.

Selection stays a runtime concern: every [store] config parses in every build, and pointing a binary at a backend it wasn’t compiled with fails at startup with a purposeful error naming the fix:

store backend `postgres` is not compiled into this binary — rebuild with `--features postgres`

The other cloud SDK trees are per-binary features on the same pattern. Every binary forwards, under the same names, the credential-sealing key source’s cloud secret managers — akv (Azure Key Vault), asm (AWS Secrets Manager), gsm (Google Secret Manager) — and, for the TOML-config binaries, the cloud app-config sources — awsconf (AWS AppConfig), azconf (Azure App Configuration). Defaults keep every cloud on, so a plain cargo build compiles exactly what it always did; slimming is strictly opt-in via --no-default-features plus only the features you need:

# AWS-only sithbitd: SQLite store, ASM key source, AWS AppConfig —
# no Azure or Google SDK code in the binary
cargo build --profile server -p mail-spooler --no-default-features --features sqlite,asm,awsconf

# Azure-only gRPC gateway
cargo build --profile server -p mail-grpc --no-default-features --features akv,azconf

The runtime contract matches the store backends: a config that names a compiled-out cloud still parses in every build, and loading it fails at startup with a purposeful error (KeySourceError::NotCompiled, or the app-config loader’s NotCompiled) naming the cargo feature to rebuild with.

What the split buys, measured 2026-07-10:

Measurementdefault (sqlite)--features all
mail-store dep-tree crates (484 before the split)268485
mail-spooler dep-tree crates (812 before the split)655essentially the pre-split tree
sithbitd binary, --profile server (stripped by the profile)24.1 MB43.2 MB
sithbitd server-profile rebuild, warm dep cache¹2 m 59 s5 m 55 s

¹ Wall time of cargo build --profile server -p mail-spooler after touching mail_store/src/lib.rs — i.e. a rebuild of mail_store and its dependents over an already-warm dependency cache, not a from-scratch build (from-scratch numbers weren’t taken; a cold image build is dominated by the cargo-chef cook layer regardless).

Dep-tree counts were measured 2026-07-10 with cargo tree -p <crate> -e normal --prefix none | sort -u | wc -l (unique lines, duplicate-marked (*) entries deduplicated by the sort).

Container images

One multi-stage Dockerfile (docker/Dockerfile) builds all six services as separate targets:

docker build -f docker/Dockerfile --target sithbitd        -t sithbit/sithbitd .
docker build -f docker/Dockerfile --target account-api     -t sithbit/account-api .
docker build -f docker/Dockerfile --target mail-grpc       -t sithbit/mail-grpc .
docker build -f docker/Dockerfile --target domain-sithbit  -t sithbit/domain-sithbit .
docker build -f docker/Dockerfile --target sithbit-ipfsd   -t sithbit/sithbit-ipfsd .
docker build -f docker/Dockerfile --target sithbit-gateway -t sithbit/sithbit-gateway .

The images carry no configuration — TOML files and environment come from the compose layer or your orchestrator. Settings can also come from AWS AppConfig or Azure App Configuration instead of a mounted file: set the binary’s {PREFIX}_AWSAPPCONFIG or {PREFIX}_AZAPPCONFIG env var (see Cloud app-config sources). Two properties of the runtime image (distroless cc-debian12) matter to an operator:

  • There is no shell in the image. docker exec into a running service is impossible; use docker logs, docker cp, and the monitoring surfaces instead. When copying a live SQLite database out with docker cp, take the -wal and -shm sidecar files too, or the copy will read as empty.
  • CA certificates are baked in, so outbound TLS (RPC providers, Filebase, smarthosts) works without extra mounts.

Published images (GHCR)

You don’t have to build the images yourself. CI publishes all six to the GitHub Container Registry (GHCR). The .github/workflows/docker-publish.yml pipeline builds every target and smoke-tests the compose stack on every push, but it only publishes on a release tag (v*) or a manual workflow_dispatch run — plain development pushes and pull requests build and smoke the images without pushing anything.

Each --target stage ships as its own repository under the workspace owner, named sithbit-<target>:

ghcr.io/<owner>/sithbit-sithbitd
ghcr.io/<owner>/sithbit-account-api
ghcr.io/<owner>/sithbit-mail-grpc
ghcr.io/<owner>/sithbit-domain-sithbit
ghcr.io/<owner>/sithbit-sithbit-ipfsd
ghcr.io/<owner>/sithbit-sithbit-gateway

A release tag pushes semver tags (1.2.3, 1.2) plus a moving latest; a manual dispatch pushes branch- and commit-sha tags instead. Pull a released image directly:

docker pull ghcr.io/<owner>/sithbit-sithbitd:latest

To run the published images instead of building locally, point the compose services at their GHCR refs with image: (dropping the build: stanza, or overriding it in a compose override file). The production example (docker-compose.prod.example.yml) already expects a registry — set it to ghcr.io/<owner> and pin a released tag rather than tracking latest:

services:
  sithbitd:
    image: ghcr.io/<owner>/sithbit-sithbitd:1.2.3
  account-api:
    image: ghcr.io/<owner>/sithbit-account-api:1.2.3
  # …domain-sithbit, sithbit-sithbit-ipfsd, sithbit-sithbit-gateway,
  #  and (chain profile) sithbit-mail-grpc likewise

GHCR packages default to private: make the ones you want public in the owner’s package settings, or docker login ghcr.io with a token that has the read:packages scope before pulling.

The compose dev stack

docker-compose.yml at the workspace root boots sithbitd, account-api, domain-sithbit, sithbit-ipfsd, and sithbit-gateway (sharing the ipfsd block volume) with empty (all-default) configs, publishing the dev ports on loopback only:

docker compose up -d --build
docker/smoke.sh          # or: probe by hand; KEEP=1 leaves the stack up

docker/smoke.sh proves each service actually answers its protocol — SMTP/IMAP/POP banners, HTTP from the two web services, a pin→fetch byte-for-byte roundtrip through sithbit-ipfsd’s pin API, and the same CID re-fetched through sithbit-gateway’s read-only surface. The script rebuilds the images itself (up -d --build) before probing, so a standalone docker/smoke.sh run cannot pass against stale local images. It also lifts the chain profile (exporting COMPOSE_PROFILES=chain for every compose call it makes, teardown included), so mail-grpc boots live and must report healthy alongside the rest — no host validator required, because the gateway’s readiness gates only on its own gRPC listener coming up, never on chain connectivity. A broken image (the historical exec-on-start regression class) therefore fails the healthy-wait instead of slipping through a config-only parse. On success the script tears the stack down unless KEEP=1. State lives in named volumes and survives down; docker compose down -v resets it.

The chain pipeline is disabled in this stack, exactly like the bare zero-config run.

Adding the chain: the chain profile

docker compose --profile chain up -d

The profile adds mail-grpc pointed at a surfpool validator running on the host (boot one by running the mail_client integration suite, which deploys and seeds the programs). Two things to know:

  • The service uses network_mode: host deliberately: a loopback-bound surfpool is not reachable through Docker’s host-gateway from a bridge network, so mail-grpc shares the host network and serves on 127.0.0.1:50051 exactly like a native run.
  • Configuration rides MAIL_GRPC_* env overrides over the gateway’s in-code defaults (configuration) — the dev stack mounts no mail_grpc.toml. Two host-side variables feed them: SITHBIT_CHAIN_RPC points the profile at a remote cluster instead of the host surfpool, and SITHBIT_CHAIN_KEYPAIR names the signing/fee-payer keypair file path on the host (absolute or ./-prefixed — never the keypair JSON content), mounted read-only into the container. Its default is the checked-in devnet test key mail_client/tests/mail-key2.json — fine against surfpool, never against a real cluster.

Cloud-store overlays

Two overlay files swap the SQLite store for the cloud backends, backed by local emulators — the same code paths a scaled-out production deployment uses:

docker compose -f docker-compose.yml -f docker-compose.aws.yml up -d    # DynamoDB Local + ElasticMQ
docker compose -f docker-compose.yml -f docker-compose.azure.yml up -d  # Azurite (tables, queues, blobs)

The emulators publish no host ports (the stack reaches them over the compose network) and their state is ephemeral. Store-backed services fail fast if their backend isn’t accepting connections yet; compose’s restart: on-failure brings them up as soon as it is.

Against real cloud backends, both stores encrypt their data at rest with provider-managed keys and no configuration: the AWS store requests SSE on the DynamoDB tables it creates (AWS-owned key) and SSE-SQS on its queues, and Azure Storage/Tables and Cosmos are always encrypted at rest by the platform. To use a customer-managed KMS key instead, set kms_master_key_id under [store.aws] — a key ID, alias, or ARN. With it set, the store creates the DynamoDB table with KMS-backed SSE under that key and the SQS queues with SSE-KMS instead of SSE-SQS; unset (the default) keeps provider-managed SSE. Azure has no customer-managed-key option yet (Key Vault CMK is future work).

Provisioning with IaC: the iac/ directory at the workspace root carries templates that create the same cloud-store footprint up front — Terraform for AWS (table, queues, optional KMS key and blob bucket), Bicep for Azure (storage account, table, queues, container). They are optional: the runtime creates everything idempotently at startup either way. Both templates also carry an opt-in mail-grpc unit (deploy_mail_grpc / deployMailGrpc, default off): the gateway container on a private subnet you bring — ECS Fargate on AWS, a VNet-integrated ACI container group on Azure — with no public ingress path, matching the private-network posture the topology appendix requires. See iac/README.md for the parameter ↔ config mapping, including each cloud’s keypair delivery.

IPFS cluster demo

docker-compose.cluster.yml is a standalone file (not an overlay): two sithbit-ipfsd nodes with [cluster] enabled over one minio bucket — the shared-bucket cluster shape. docker/cluster-smoke.sh is its chaos probe: pin through node 1, stop node 1, fetch the same CID through node 2.

Outbound mail and port 25

Many hosting providers — most cloud VPS platforms, and virtually all consumer ISPs — block outbound connections on port 25 by default to curb spam relayed from compromised or careless hosts. If sithbitd’s relay worker sees connection timeouts or refusals handing mail to a recipient’s MX, this is almost always the cause rather than a bug in the relay logic; confirm with a manual connection test from the box sithbitd runs on (nc -zv <mx-host> 25).

Two ways to unblock it, in order of preference:

  • Ask the provider to lift the block. Most cloud providers (AWS, Azure, DigitalOcean, …) will do this for a verified account in good standing on request. It’s the only path that keeps outbound delivery under your own PTR/DKIM identity end to end. (Google Cloud is the exception: GCE’s outbound-25 block is unconditional — see Hosting on Google Cloud.)

  • Route through a third-party gateway as a smarthost. If the block can’t be lifted — shared hosting, some residential/VPS plans, or while a request is pending — point [spooler.smarthost] at a provider like SendGrid or Amazon SES. These accept mail over authenticated submission on 587/465, so an outbound port 25 block doesn’t affect them, and the gateway does the actual MX delivery from IPs with established sending reputation:

    [spooler.smarthost]
    host = "smtp.sendgrid.net"      # or email-smtp.<region>.amazonaws.com for SES
    port = 587
    user = "apikey"                 # SendGrid: literal string "apikey"; SES: your SMTP username
    password = "<api-key-or-smtp-password>"
    require_tls = true
    

Either way, DNS setup still applies: publish SPF that include:s the gateway (SendGrid: include:sendgrid.net; SES: include:amazonses.com), and DKIM — most gateways can sign on your behalf too, but [spooler.dkim] keeps signing under your control if you’d rather sign locally before handing off to the smarthost.

Direct-to-MX delivery (no smarthost) also honors each recipient domain’s published MTA-STS policy by default (RFC 8461): an enforce-mode policy means verified TLS to a policy-matching MX, or a deferral on the normal retry schedule — never a plaintext fallback. A recurring “no MX matches the MTA-STS policy” deferral in the logs means the recipient’s MX records disagree with its own published policy, not a local misconfiguration. Smarthost deployments are unaffected — the gateway does the actual MX delivery, so its MTA-STS handling applies, not sithbitd’s (see the [spooler] reference).

This only affects outbound relay. Inbound MX on port 25 (the rest of the world delivering mail to you) is rarely blocked by hosting providers; if it is, that’s a harder blocker to work around and usually means the provider isn’t suited to running a mail server at all.

Hosting on Google Cloud

Google Cloud is the proof of the vendor-independence claim above: a full SithBit deployment runs there with zero GCP-specific application code — every piece rides a backend that already exists.

  • Blobs = Google Cloud Storage over its S3-interop endpoint. GCS speaks the S3 XML API against HMAC credentials, and the blob store’s S3 backend sends exactly the path-style requests that endpoint expects — so a GCS bucket is just an [store.blobs] edit:

    [store.blobs]
    kind       = "s3"
    endpoint   = "https://storage.googleapis.com"
    region     = "auto"
    bucket     = "sithbit-mail"          # GCS bucket names are globally unique
    access_key = "<HMAC access id>"
    secret_key = "<HMAC secret>"
    
  • Tables, leases, and the job queue = Cloud SQL. There is deliberately no gcp store kind: [store] kind = "postgres" carries all three in one database, and a Cloud SQL Postgres instance is a plain url away. One instance, one database — the schema migrates itself at startup.

  • Secrets = Google Secret Manager. Every signing keypair (key sources) can load with kind = "gsm"; auth is ambient (a service account / workload identity with secretAccessor on the secret), so no credential material appears in config or env at all.

  • Outbound port 25 is hard-blocked on GCE — plan on a smarthost. Unlike AWS/Azure, Google does not lift the block on request; [spooler.smarthost] through a gateway that accepts authenticated submission on 587/465 is the supported shape. Inbound MX on port 25 is unaffected — the rest of the world can still deliver to you directly.

  • The mail-port tier belongs on GCE, not serverless. The SMTP/IMAP/POP listeners need the connecting client’s real source IP (DNSBL, SPF, rate limits), so run sithbitd on a managed instance group behind an external passthrough Network Load Balancer — the passthrough part is what preserves source addresses. This is the GCP analog of the standing Azure rule (VM scale sets, never ACI, for mail ports). Cloud Run and the global HTTP(S) load balancers proxy connections and are unsuitable for the mail tier — containers behind an L4 balancer that speaks PROXY protocol are the exception (see Hosting on a generic VM).

  • The mail-grpc gateway can be serverless. It’s a private gRPC broker with no source-IP requirement — an internal-ingress-only Cloud Run v2 service fits, with the gsm key source delivering the fee-payer keypair.

The iac/gcp Terraform module provisions this footprint — the GCS bucket + HMAC pair always; Cloud SQL and the Cloud Run mail-grpc unit as default-off opt-ins — with outputs shaped to paste into the config sections above. See iac/README.md for the variable ↔ config mapping tables and validation gates.

Hosting on a generic VM: Postgres + any S3-compatible storage

The Google Cloud recipe above generalizes. Because every infrastructure touchpoint sits behind a trait (Your stack, not a vendor’s), any provider that rents you a VM, a Postgres database, and S3-compatible object storage runs the full stack — no provider SDK, no provider-specific store kind, no code change. The recipe is two config edits:

  • Tables, leases, and the job queue = any Postgres. [store] kind = "postgres" carries all three in one database. Create the database first — managed or self-hosted, the server never issues CREATE DATABASE — and the schema migrates itself idempotently at startup, so the connection URL is the only setting:

    [store]
    kind = "postgres"
    
    [store.postgres]
    url = "postgres://sithbit:<password>@<host>:5432/sithbit"
    
  • Blobs = the provider’s S3-compatible object storage. The same [store.blobs] shape as the GCS snippet above, pointed at the provider’s endpoint with its HMAC-style key pair:

    [store.blobs]
    kind       = "s3"
    endpoint   = "https://<provider's object-storage endpoint>"
    region     = "<provider region>"
    bucket     = "sithbit-mail"
    access_key = "<access key>"
    secret_key = "<secret key>"
    

    The blob store sends path-style requests (endpoint/bucket/key). Every provider listed below accepts them, but where a provider’s docs standardize on virtual-hosted addressing (Hetzner’s do), smoke-test a put/get against your actual bucket at onboarding rather than at first delivery.

  • IPFS blocks ride the same trait. [ipfs.blobs] takes the same kind = "s3" shape — the same bucket or a second one, your choice — and a shared bucket is already the cluster shape when the fleet grows past one node.

Four operational caveats stand in for what a big cloud would otherwise absorb:

  1. Build features. A default cargo build compiles only the SQLite backend; a source build of this recipe needs --features postgres,s3-blobs (or all) on the store-consuming binaries — see Slim-build features. The published GHCR images build all, so container deployments skip the concern entirely.
  2. TLS. Certificate sources are PEM files (or cloud-secret PEMs) — there is no built-in ACME client, and the TLS acceptor loads certificates once at startup. Run certbot (webroot or DNS-01 for the mail hostnames) with a --deploy-hook that restarts sithbitd, or renewals will sit unused on disk while the listeners keep serving the old certificate.
  3. Secrets. No cloud secret manager is required: file-based key sources are the default everywhere. Provision the key files with tight permissions and back them upcredential.key, the JWT key, and any DKIM key are the unrecoverable pieces (Monitoring and backups).
  4. Outbound port 25 is where commodity providers differ most — see Outbound mail and port 25. A blocked port is not a disqualifier: [spooler.smarthost] is the fallback, and the list below ranks each provider’s posture.

Containers behind a load balancer are viable for the mail tier — when the balancer speaks PROXY protocol. The “VMs, not serverless” rule in the Google Cloud section is about source-IP fidelity, not containers as such: the listeners already accept a PROXY protocol preamble (proxy_protocol = true in each [*.server] section) and recover the real client address for DNSBL, connection limits, and SPF. A container platform fronted by an L4 balancer that injects the preamble — an HAProxy/Traefik ingress, or an NLB with PROXY protocol v2 enabled — preserves everything the mail tier needs; what stays ruled out is any HTTP proxy or balancer that rewrites sources without PROXY protocol. Never enable the switch on a listener clients can reach directly — the preamble is spoofable.

Choosing a commodity provider

Ordered by fit for this recipe. Port-25 posture and PTR/rDNS control weigh heaviest (they are what sender reputation hangs on), managed Postgres second; a blocked port 25 demotes a provider to a smarthost caveat, it does not disqualify.

  1. Hetzner — the best price/performance of the six, with a documented, routinely granted port-25 unblock (request it after one month and the first paid invoice — see the cloud server FAQ) and first-class self-service PTR records (console, API, and Terraform). Object Storage is S3-compatible at €5.99/mo including 1 TB. Two caveats: there is no managed Postgres — self-host it on a VM + volume or buy it from a third party — and its Object Storage docs standardize on virtual-hosted addressing, so run the path-style smoke test above at onboarding.
  2. OVHcloud — the only provider here with port 25 open by default in its classic regions, so it is the one that can deliver under its own identity on day one; managed Postgres, S3-compatible Object Storage, and self-service PTR complete the full recipe with no gaps. Caveats: keep the mail tier out of its Local Zones (port 25 blocked there, no unblock path), and its reactive anti-spam can block an instance’s IP mid-operation (recovery is self-service). Terraform spans two providers (OVH + OpenStack).
  3. Scaleway — the most deterministic unblock of the bunch: enabling SMTP is a console checkbox after identity verification, with no human review. Managed Postgres is the cheapest here (from roughly €11/mo), and Object Storage plus flexible-IP PTR complete the recipe. The constraint is geography: regions are EU-only.
  4. Linode (Akamai) — the full recipe is present (Aiven-powered managed Postgres, the cheapest object storage of the six at $5/mo including 250 GB, self-service PTR once forward DNS resolves), but the SMTP unblock is a human-reviewed support ticket — and 465/587 are blocked too until it resolves, so even the smarthost fallback needs the ticket first (or a gateway reached over an HTTPS API rather than SMTP submission).
  5. Vultr — the recipe applies mechanically: managed Postgres, object storage, self-service PTR, and 587 open from day one, so a smarthost works immediately. But the port-25 unblock is explicitly not guaranteed — plan on the smarthost semi-permanently — and its managed-Postgres floor (~$45/mo) is the priciest of the providers that have one.
  6. DigitalOcean — demoted, not disqualified: 25, 465, and 587 are all blocked for new accounts with no reliable unblock, and PTR control is indirect (the Droplet’s name becomes the PTR; Reserved IPs get none) — the weakest sender-reputation posture here. Everything else — managed Postgres from $15/mo, Spaces, the best-documented S3 compatibility, the best operator UX — is excellent for a smarthost-first deployment.

No SithBit code in any of this is provider-specific: the recipe is the GCS pattern above with a different endpoint, and the trait seams do the rest.

Production

docker-compose.prod.example.yml is the annotated production shape: copy it, search for CHANGE, and fill in your registry, config files, and keys. Sanity-check with docker compose -f <file> config before up -d. The structural decisions it encodes:

  • Real config lives in mounted TOML files, not a wall of env vars. Start from mail_spooler/sithbitd.example.toml and account_api/account_api.toml; containers find the file via SITHBITD_CONFIG / ACCOUNT_API_CONFIG. The third option is a cloud app-config source — AWS AppConfig or Azure App Configuration, bootstrapped by a single env var — when a mount is the awkward part of your orchestrator. For that path, iac/appconfig/ ships ready-to-import production documents for all six services (AWS freeform TOML profiles whose comments survive verbatim in the store, and a generated Azure kvset file whose per-key description tags carry the same text), plus the store-creation and import runbooks in iac/README.md.
  • Implicit-TLS mail ports are the production primaries (RFC 8314): the compose example publishes host 25→2525 (MX), 465→2465 (submission, SMTPS), 993→2993 (IMAPS), and 995→2995 (POP3S) — the three submission/access listeners run implicit_tls = true, so the connection is born encrypted with no STARTTLS round trip, while MX on 25 stays plaintext-with-STARTTLS by nature. The binds move to 0.0.0.0 in the TOML; the images never need root or privileged ports. The legacy STARTTLS/STLS listeners on 587/143/110 are opt-in secondaries — commented out in both the compose file and sithbitd.example.toml; uncomment them (and their matching TOML listeners) only for old clients.
  • TLS for the mail protocols comes from the [smtp.tls] / [submission.tls] / [imap.tls] / [pop.tls] sections over a mounted /certs directory (each a file or Key Vault key source), and require_tls is on by default for the submission edge, IMAP, and POP — credentials are declined until the connection is protected (RFC 8314). The two HTTP services (account-api, domain-sithbit) stay loopback-published and belong behind a TLS-terminating reverse proxy.
  • SQLite allows exactly one sithbitd. Never scale the service while [store] kind = "sqlite". For replicas, switch the TOML to the aws/azure store and work through the Scaling out checklist.
  • The store volume is precious — it holds sithbit.db, credential.key, jwt.key, and the blob directory. Back it up; see Monitoring and backups for what is unrecoverable. The alias-index volume is not precious: it re-syncs from chain history.
  • Secrets stay out of the compose file. The mail-grpc fee-payer/signing keypair is a key source (keypair in mail_grpc.toml — a mounted file path or a cloud secret manager: Key Vault, Secrets Manager, or GSM): the dev compose mounts the keypair file read-only via SITHBIT_CHAIN_KEYPAIR, and the production example mounts mail_grpc.toml plus a separate read-only keypair file — or drops that mount entirely for the secret-manager forms. (The non-secret rest of mail_grpc.toml can likewise arrive from a cloud app-config source instead of a mount — carrying the key source’s coordinates, never the key.) The domain-sithbit delegate keypair authorizes domains on-chain — an operational hot key that can never sweep postoffice funds, rotated on a schedule via postmaster delegate (the service picks up a swapped key file without a restart). The ownership secrets are the offline key-ceremony seeds, which never touch a deployment host at all: see the postmaster key custody runbook.

After the stack is up, work through DNS setup so the world can find your MX, then Monitoring and backups for day-2 operation.