Running a mail server
A SithBit deployment is at most six services, all built from this workspace:
| Service | Binary | Role | Needed when |
|---|---|---|---|
sithbitd | mail-spooler | SMTP MX + submission, IMAP, POP, the spooler workers — and optionally the embedded IPFS node — in one process | always (the embedded IPFS node only with [ipfs] kind = "embedded" — a fleet points at sithbit-ipfsd or a pinning service instead) |
account-api | account-api | wallet-challenge login → JWT; mail passwords, timezone, DND schedules | users manage their accounts |
mail-grpc | mail-grpc | gRPC gateway to the Solana programs (postage checks, SendMail, aliases) | mail should reach the chain |
domain-sithbit | domain-sithbit | DNS-based domain verification and on-chain domain authorization | you operate the domain registry |
sithbit-ipfsd | sithbit-ipfsd | the self-hosted IPFS node as a standalone daemon: HTTP pin API + optional public swarm | a fleet shares one node via [ipfs] kind = "remote" (a single sithbitd can embed the node instead) |
sithbit-gateway | sithbit-gateway | read-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, orpop-serverbinaries 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, orcloudflare. 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 —postgresagainst Cloud SQL is the GCP shape; see Hosting on Google Cloud.) - Mail bodies pin through a
blob store that is either
localdisk 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 sharedsithbit-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 plaincargo buildcompiles 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:
| Measurement | default (sqlite) | --features all |
|---|---|---|
mail-store dep-tree crates (484 before the split) | 268 | 485 |
mail-spooler dep-tree crates (812 before the split) | 655 | essentially the pre-split tree |
sithbitd binary, --profile server (stripped by the profile) | 24.1 MB | 43.2 MB |
sithbitd server-profile rebuild, warm dep cache¹ | 2 m 59 s | 5 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 execinto a running service is impossible; usedocker logs,docker cp, and the monitoring surfaces instead. When copying a live SQLite database out withdocker cp, take the-waland-shmsidecar 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: hostdeliberately: a loopback-bound surfpool is not reachable through Docker’shost-gatewayfrom a bridge network, somail-grpcshares the host network and serves on127.0.0.1:50051exactly like a native run. - Configuration rides
MAIL_GRPC_*env overrides over the gateway’s in-code defaults (configuration) — the dev stack mounts nomail_grpc.toml. Two host-side variables feed them:SITHBIT_CHAIN_RPCpoints the profile at a remote cluster instead of the host surfpool, andSITHBIT_CHAIN_KEYPAIRnames 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 keymail_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
gcpstore kind:[store] kind = "postgres"carries all three in one database, and a Cloud SQL Postgres instance is a plainurlaway. 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 withsecretAccessoron 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
sithbitdon 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
gsmkey 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 issuesCREATE 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 samekind = "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:
- Build features. A default
cargo buildcompiles only the SQLite backend; a source build of this recipe needs--features postgres,s3-blobs(orall) on the store-consuming binaries — see Slim-build features. The published GHCR images buildall, so container deployments skip the concern entirely. - 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-hookthat restartssithbitd, or renewals will sit unused on disk while the listeners keep serving the old certificate. - 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 up —
credential.key, the JWT key, and any DKIM key are the unrecoverable pieces (Monitoring and backups). - 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.
- 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.
- 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).
- 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.
- 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).
- 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.
- 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.tomlandaccount_api/account_api.toml; containers find the file viaSITHBITD_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-keydescriptiontags carry the same text), plus the store-creation and import runbooks iniac/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 to0.0.0.0in 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 andsithbitd.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/certsdirectory (each a file or Key Vault key source), andrequire_tlsis 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 theaws/azurestore and work through the Scaling out checklist. - The
storevolume is precious — it holdssithbit.db,credential.key,jwt.key, and the blob directory. Back it up; see Monitoring and backups for what is unrecoverable. Thealias-indexvolume 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 (
keypairinmail_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 viaSITHBIT_CHAIN_KEYPAIR, and the production example mountsmail_grpc.tomlplus a separate read-only keypair file — or drops that mount entirely for the secret-manager forms. (The non-secret rest ofmail_grpc.tomlcan likewise arrive from a cloud app-config source instead of a mount — carrying the key source’s coordinates, never the key.) Thedomain-sithbitdelegate keypair authorizes domains on-chain — an operational hot key that can never sweep postoffice funds, rotated on a schedule viapostmaster 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.