Skip to content

Architecture Overview

Vectis is a containerised, self-hosted email platform. Every component runs in a Docker container, isolated across four purpose-built networks. This page covers the container layout, data flow, database design, and security model.

Internet
|
+-------------+-------------+
| | |
Port 80/443 Port 25/587/465 Port 993/995
| | |
+--------+--------+ | +--------+--------+
| Traefik | | | Dovecot |
| (reverse proxy) | | | (IMAP/POP3) |
+--------+--------+ | +--------+--------+
| | |
vectis-frontend | vectis-mail
| | |
+--------+--------+ | +--------+--------+
| Go API | +--->| Postfix |
| + Admin UI SPA | | (SMTP) |
+--------+--------+ +--------+--------+
| | |
vectis-data vectis-mail vectis-data
vectis-orchestrator | |
| +----+----+ |
| | Rspamd | |
+--------+--------+ | (spam/ | |
| Orchestrator | | DKIM) | |
| (Docker socket) | +----+----+ |
+--------+--------+ | |
| +----+----+ |
vectis-data | ClamAV | |
vectis-orchestrator| (opt.) | |
| +---------+ |
| |
+--------+--------+ +---------+ |
| Postgres | | Valkey | |
| (database) | | (cache) | |
+------------------+ +---------+ |
| | |
+------ vectis-data ----------+

Vectis runs 10 containers (9 if ClamAV is disabled):

ContainerRoleNetworks
TraefikReverse proxy, TLS termination, ACME certificatesfrontend
Go APIREST API + embedded admin UI (React SPA)frontend, data, orchestrator
PostfixSMTP server (inbound + outbound mail)mail, data
DovecotIMAP/POP3 server, mail storagemail, data
RspamdSpam filtering, DKIM signing, greylistingmail, data
ClamAVAntivirus scanning (optional)mail
PostgresPrimary databasedata
ValkeySession cache, rate limiting, Rspamd datadata
OrchestratorUpdate lifecycle, Docker socket accessorchestrator, data
acme.shMail TLS certificate managementfrontend

The admin UI is not a separate container. The React SPA is compiled to static files and served directly by the Go API container.

Network isolation follows the principle of least privilege. Each container connects only to the networks it needs.

NetworkTypePurpose
vectis-frontendbridge (external)Web traffic: Traefik to API and UI. The only network with external access (required for ACME challenges).
vectis-mailbridge (internal)Mail service communication: Postfix, Dovecot, Rspamd, ClamAV.
vectis-databridge (internal)Database and cache access: Postgres, Valkey, and all services that query them.
vectis-orchestratorbridge (internal)Orchestrator control plane: API to Orchestrator communication.
  • Postfix and Dovecot query Postgres directly via read-only SQL users (vectis_postfix, vectis_dovecot). This avoids routing every mail delivery through the API, which would add latency and create a single point of failure.
  • ClamAV is fully isolated to the mail network. It only communicates with Rspamd (for scanning) and Postfix (milter protocol). No database or API access.
  • The Orchestrator is the only container with Docker socket access, mounted read-only. It controls the update lifecycle (pull images, restart services, rollback).

Only three containers have ports mapped to the host:

ContainerPortsProtocol
Traefik80 (HTTP), 443 (HTTPS)TCP — TLS terminated by Traefik
Postfix25 (SMTP), 465 (SMTPS), 587 (Submission)TCP — mail protocols handle their own TLS
Dovecot993 (IMAPS), 995 (POP3S)TCP — implicit TLS

Mail ports are direct-mapped (not proxied through Traefik) because mail protocols handle their own TLS via STARTTLS and implicit TLS. All inter-container communication uses Docker DNS resolution.

External MTA --> Port 25 --> Postfix --> Rspamd (spam check)
|
[ClamAV scan if enabled]
|
Rspamd --> Postfix
|
LMTP (port 24) --> Dovecot
|
Dovecot writes to Maildir
|
Postfix notifies API (internal webhook)
|
API fires mail.received webhook
  1. External mail arrives at Postfix on port 25.
  2. Postfix queries Postgres to verify the recipient domain and mailbox exist (read-only SQL user).
  3. Postfix passes the message to Rspamd via the milter protocol for spam/virus scanning.
  4. Rspamd optionally forwards to ClamAV for antivirus scanning.
  5. If the message passes, Postfix delivers it to Dovecot via LMTP on port 24.
  6. Dovecot stores the message in the Maildir at /var/vectis/mail/<domain>/<user>/Maildir/.
  7. A Postfix notification script POSTs to the API’s internal endpoint.
  8. The API stores message metadata and fires webhook events.
API (POST /send) --> Postfix (port 25) --> Rspamd (DKIM signing)
|
Rspamd --> Postfix
|
Postfix --> Remote MTA
  1. A client sends POST /api/v1/send with the message payload.
  2. The API validates domain ownership, RBAC, and abuse detection.
  3. The API submits the message to Postfix via SMTP.
  4. Postfix passes through Rspamd, which adds the DKIM signature.
  5. Postfix delivers the message to the recipient’s MTA.
  6. The API stores message metadata and fires the mail.sent webhook.
Browser/Client --> HTTPS (port 443) --> Traefik --> Go API
|
Postgres (read/write)
Valkey (sessions, cache)
  1. Requests arrive at Traefik on port 443 (TLS terminated).
  2. Traefik routes to the Go API container based on path prefix.
  3. The API authenticates via session cookie or API key.
  4. The API reads from and writes to Postgres (using vectis_api, the full-access user).
  5. Session lookups hit Valkey first for fast validation.

Postgres is the primary data store. All tables use UUIDv7 primary keys (time-sortable, clustering-safe) generated by the Go application.

TablePurposeKey fields
domainsMail domainsname, active, dkim_selector, spam_threshold, max_mailboxes
mailboxesEmail accountsdomain_id, local_part, password_hash (Argon2id), quota_mb
aliasesMail forwardingdomain_id, source_local_part, destination
adminsAdmin accountsemail, password_hash, role, totp_secret, totp_enabled
sessionsActive sessionsadmin_id, token_hash (SHA-256), ip_address, expires_at
api_keysAPI keysadmin_id, key_hash (SHA-256), key_prefix, scoped_domain_ids
audit_logChange historyadmin_id, action, resource_type, resource_id, details (JSONB)
messagesMessage metadatadomain_id, message_id, direction, sender, recipients, status
mail_statsAggregate statsdomain_id, sent, received, bounced, spam, failed
webhooksWebhook registrationsurl, events, secret, domain_id
orchestrator_historyUpdate operationsaction, status, plan_summary (JSONB), snapshot_path
backup_jobsAsync backup opsaction, status, progress, backup_path

Principle of least privilege applied at the database level:

UserAccessUsed by
vectis_postfixRead-only: domains, mailboxes, aliasesPostfix (SQL lookups for mail routing)
vectis_dovecotRead-only: domains, mailboxesDovecot (authentication + mailbox location)
vectis_apiFull read/write on all tablesGo API server

Postfix and Dovecot never need write access. If either container is compromised, the attacker cannot modify data.

  • All primary keys are UUIDv7, generated by Go (not Postgres sequences).
  • All timestamps are TIMESTAMPTZ stored in UTC.
  • Foreign keys on domain deletion use RESTRICT (prevents accidental cascade).
  • Foreign keys on admin deletion use SET NULL (preserves audit history) or CASCADE (sessions).
  • Passwords use Argon2id (memory=64 MB, iterations=3, parallelism=4).
  • Forward-only migrations via golang-migrate. Rollback is snapshot-based (pg_dump before apply, psql to restore).

The four-network design ensures that a compromise of one service limits the blast radius:

  • ClamAV compromise: attacker can only reach Rspamd and Postfix on the mail network. No database, no API.
  • Admin UI compromise: the SPA communicates only via the authenticated API. No direct database access.
  • Rspamd compromise: access to mail network services and Valkey (ephemeral data only). No direct API access.
  • Admin sessions use HMAC-signed cookies with HttpOnly, Secure, SameSite=Strict.
  • Session tokens are 256-bit random, stored as SHA-256 hashes in Postgres.
  • API keys use the vectis_ prefix, stored as SHA-256 hashes. Raw keys are shown only at creation.
  • TOTP MFA is available for admin accounts (TOTP secret encrypted at rest).
  • Three RBAC roles (super_admin, admin, domain_admin) with domain-level scoping for domain_admin.
  • Postfix notification script authenticates to the API via an internal token (X-Internal-Token header).
  • Orchestrator to API communication uses mTLS (preferred) or bearer token (fallback).
  • All internal communication stays on Docker internal networks (not routable from the host network).
  • Secrets live in secrets.yaml (mode 0600), separate from configuration.
  • Database credentials use three separate users with minimal privileges.
  • Backup archives are encrypted with a dedicated key (or the API secret as fallback).
  • DKIM private keys are stored at /var/vectis/dkim/ with restricted file permissions.
  • The orchestrator takes a pg_dump snapshot before every update.
  • Health checks run after every update. If any service fails, automatic rollback restores the snapshot and reverts container images.
  • Advisory locks (pg_advisory_lock(1)) prevent concurrent update operations.
  • The orchestrator state machine enforces a strict sequence: plan, snapshot, migrate, pull, restart, health check.