Running a mail server
Running a SithBit server gives a domain operator a potential revenue stream. The domain’s authority wallet collects 10% of the postage on each message settled under it, plus a share of stamp fees and reply bounties (see Economics). Self-service authorization uses a DNS record and costs 0.01 SOL once. Mail between SithBit mailboxes settles on-chain and is stored on IPFS, so that path does not depend on an IP reputation gate.
Revenue depends on real mail volume; hosting, RPC, and IPFS costs remain off-chain expenses. SithBit is alpha software with no production deployment, so model those costs before treating settlement income as a business case.
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. The
account-api unit and its opt-in shared summary cache are covered per
cloud under Hosting on AWS, Hosting on
Azure and Hosting on Google
Cloud below.
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 AWS
AWS is the reference cloud for the two managed stores: [store] kind = "aws" (DynamoDB + SQS), blobs on S3, and every keypair loadable from
Secrets Manager with kind = "asm". The iac/aws Terraform module
provisions that footprint — the table, queues, optional KMS key and blob
bucket always; the mail-grpc and account-api units as default-off
opt-ins on a private subnet you bring (ECS Fargate, no public ingress).
iac/README.md carries the variable ↔ config mapping tables. The same
rules as everywhere apply to the mail-port tier: run sithbitd on
instances behind a passthrough network load balancer, never behind a
proxy that hides the client’s source address.
The account-api summary cache: ElastiCache
A fleet of account-api tasks behind a load balancer shares one
plaintext summary cache — the shared
backend of
[cache] kind = "redis" — or each task serves a different view of the
same mailbox. A single task is served by its in-process cache and needs
none of this, so the cache is opt-in and off by default:
create_account_api_cache = true adds an ElastiCache Valkey
replication group of one cache.t4g.micro node, TLS-only in transit
and encrypted at rest (under the module’s KMS key when it has one),
placed in the same private subnets as the task behind a security-group
pair — the cache admits port 6379 from the account-api security group
only, and the account-api group (which has no default egress) gains the
matching egress rule. The wiring into the task is three env entries:
ACCOUNT_API_CACHE__KIND=redis, ACCOUNT_API_CACHE__REDIS_URL
(rediss://<primary endpoint>:6379/, host-only) and an asm key-source
selector under ACCOUNT_API_CACHE__REDIS_AUTH__*; the token itself
never appears in an env value.
The operator step is that the token lives in two places by design.
ElastiCache needs it at creation (account_api_cache_auth_token,
sensitive, and — like every ElastiCache token — in the state file), and
the API fetches it at load from a Secrets Manager secret you create
yourself and name in account_api_cache_auth_asm ({ secret_id, region }, the jwt_key_asm shape; the task role gets GetSecretValue on
exactly that secret). Store the same token in both; the module never
reads or writes the secret. Changing the variable later rotates in
place (ROTATE keeps the previous token valid until the next update).
No live end-to-end deployment of this cache has been done yet — the
project is alpha; the module is validated statically by the gate’s
iac:terraform-aws leg, whose terraform test suite fences the cache’s
preconditions and env wiring under a mock provider.
Hosting on Azure
Azure mirrors the AWS shape with [store] kind = "azure" (Tables +
Queues), blobs in a container on the same storage account, and keypairs
from Key Vault with kind = "akv". The iac/azure Bicep template
provisions the storage account with its table, queues and container,
plus the mail-grpc and account-api units as default-off opt-ins: each a
VNet-integrated ACI container group on a delegated subnet you bring.
The standing rule for the mail-port tier is VM scale sets, never ACI —
the listeners need the client’s real source address. iac/README.md
carries the parameter ↔ config mapping tables.
The account-api summary cache: Azure Cache for Redis
The same opt-in as on AWS, for the same reason (one shared plaintext
summary cache per fleet; a single replica needs none): deployRedisCache = true adds an Azure Cache for Redis Basic C0 instance with TLS 1.2
minimum, the non-SSL port disabled and public network access off, so
its only path is a private endpoint the template creates — on
redisPrivateEndpointSubnetId, a second, undelegated subnet on the
account-api VNet you bring (a subnet delegated to ACI can host nothing
else), resolved through a privatelink.redis.cache.windows.net private
DNS zone linked to that VNet. The container group’s env gains
ACCOUNT_API_CACHE__KIND=redis, ACCOUNT_API_CACHE__REDIS_URL
(rediss://<name>.redis.cache.windows.net:6380/, host-only) and an
akv selector under ACCOUNT_API_CACHE__REDIS_AUTH__* pointing at
accountApiKeyVaultUri; the access key never appears in an env value,
and the template never reads it (listKeys() would put it there).
The operator step, after the deploy: copy the cache’s primary
access key into that vault under the name accountApiCacheSecretName
(default account-api-cache-auth) — az keyvault secret set --vault-name <vault> --name <accountApiCacheSecretName> --value "$(az redis list-keys --name <redisCacheName> --resource-group <rg> --query primaryKey -o tsv)". The group’s managed
identity already holds secret read on the whole vault (the
one-vault-many-secrets pattern the JWT and credential keys use), so no
new grant is needed. accountApiKeyVaultUri must be set: Bicep has no
precondition, and an empty vault URI leaves the selector broken until
the binary refuses to start. No live end-to-end deployment of this
cache has been done yet — the project is alpha; the template is
validated by the gate’s iac:bicep-azure leg.
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 account-api summary cache: Memorystore
The same opt-in as on the other two clouds: a fleet of Cloud Run
account-api replicas shares one plaintext summary cache, a single
replica needs none, so deploy_redis = true adds a Memorystore for
Redis BASIC 1 GB instance with auth_enabled and TLS
(SERVER_AUTHENTICATION), attached over private-services-access to
account_api_network — the same VPC the unit’s Direct VPC egress rides,
which must already carry a servicenetworking connection and reserved
range (account-level plumbing the module leaves to you, as it does for
Cloud SQL). The service’s env gains ACCOUNT_API_CACHE__KIND=redis,
ACCOUNT_API_CACHE__REDIS_URL (rediss://<host>:<port>/, host-only)
and a gsm selector under ACCOUNT_API_CACHE__REDIS_AUTH__*; the auth
string never appears in an env value.
Two operator steps. First, the instance generates its own auth
string and the module never copies it into Secret Manager (a secret
version fed from state would put the token in state): after apply, copy
terraform output -raw redis_auth_string into the pre-existing Secret
Manager secret you name in account_api_cache_auth_gsm ({ secret, project }, the jwt_key_gsm shape; the service account is granted
secretAccessor on exactly that secret). Second, none of yours:
Memorystore’s SERVER_AUTHENTICATION mode presents a certificate from
an instance-specific Google-managed CA (the instance’s
server_ca_certs) that no platform root store holds, so the module
writes that CA — public material, already in state as an instance
attribute, not the token the no-vault stance protects — into a Secret
Manager secret it creates (sithbit-account-api-cache-ca, output
redis_ca_secret), mounts it into the service as a secret volume at
/etc/sithbit/redis-ca/ca.pem, grants the service account
secretAccessor on it, and sets ACCOUNT_API_CACHE__REDIS_CA to that
path — the
[cache] redis_ca
file selector. A Google CA rotation adds a certificate to the list; the
next terraform apply re-renders the version and rolls the service.
Until the CA is trusted the API runs fail-open (every request re-reads
the store) rather than failing, which is why the selector refuses a
certificate-less value at startup instead. No live end-to-end
deployment of this cache has been done yet — the project is
alpha; the module is validated statically by the gate’s
iac:terraform-gcp leg, whose terraform test suite fences the cache’s
preconditions and env wiring under a mock provider.
The iac/gcp Terraform module provisions this footprint — the GCS
bucket + HMAC pair always; Cloud SQL, the Cloud Run mail-grpc and
account-api units and the Memorystore cache 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. Pair the switch with a proxy_trusted allowlist naming the
balancer’s CIDRs; startup refuses proxy_protocol = true without one.
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 — including sizing the account API’s caches from the metrics it exports, once real traffic has warmed them.