Architecture

One API service plus workers, one system of record, and the AWS services around them.

Secure File Transfer runs on AWS as one API service, a few workers built from the same codebase, and one system of record in PostgreSQL. This page covers the containers, the two file paths, the queues and tenant isolation.

Few moving parts

The design adds a component only when a requirement needs it.

  • One codebase. The API service and every worker are built from one container image with different entry points, so routes and jobs share the data model and the same permission checks (ADR-0002).
  • One system of record. PostgreSQL holds tenants, users, transfers, files, sessions, download grants, DLP decisions, audit events and the outbox. The other stores hold file bytes (S3), queued work (SQS) or short-lived counters (Valkey).
  • One language. v1 is Python only (ADR-0010). The web app is static files on a CDN, not another service.

Containers and AWS services

Production runs in Tokyo across three Availability Zones, with a pilot light in Osaka for disaster recovery (see Reliability).

ContainerJob
API serviceEvery HTTP call: sign-in, sessions, uploads, verification, download grants and the download stream, revocation. At least 3 pods across 3 AZs
Outbox relayPublishes outbox rows from PostgreSQL to SQS
Scan workerStreams each file to ClamAV and the DLP inspector in one pass and records the results
ClamAV and DLP inspectorTheir own pods with no credentials; the inspector has no network access at all
Notification workerCreates link tokens and codes, sends every email through SES
Lifecycle jobsExpiry, deletion in both regions, reconciliation, audit archiving. The only workload that deletes objects
Operator APITenant provisioning. No load balancer; operators reach it by port-forward

They run on EKS, backed by Aurora PostgreSQL, S3, SQS, ElastiCache for Valkey, SES, Cognito, KMS, Secrets Manager and GuardDuty, with Route 53, CloudFront and WAF at the edge. Outbound traffic leaves only through VPC endpoints or an allowlisting egress proxy (ADR-0021).

Upload path

File bytes go straight from the browser to S3. The API never carries them, but it decides every state change.

  1. The member creates a draft transfer and declares each file's name and size. The API starts an S3 multipart upload, in a separate bucket for files over 2 GB, and reserves the size against the tenant's quota.
  2. The API returns pre-signed part URLs, each valid for up to 15 minutes and signed for one part's exact size. The browser uploads parts in parallel, and after a dropped connection sends only the missing ones.
  3. Completion runs in three phases, with no database lock held across an S3 call. A short locked transaction re-checks the transfer and limits. The API assembles the parts and reads the real size and object version. A second locked transaction re-checks and moves the file to scanning, so a revocation in between wins.
  4. The scan worker streams the recorded object version to ClamAV and the DLP inspector at once, records both results in one transaction, then tags the object. GuardDuty scans files above 2,000,000,000 bytes, which ClamAV can't handle (ADR-0019).
  5. Each scan result re-runs one evaluation under a row lock. Once every file is shareable and the DLP decision allows it, the transfer is sent and the notification worker emails each recipient their own link.

Download path

Downloads stream through the API service under a grant bound to the verified session (ADR-0018).

  1. The emailed link carries its token in the URL fragment (/t#token), which browsers never send to servers. The web app posts it in a request body, so tokens stay out of logs.
  2. The recipient asks for a code. After per-IP, per-link and per-address limits, a 6-digit code goes to the invited address.
  3. The recipient submits token and code. The API checks attempts, lockout and expiry, then opens a link session in a SameSite=Strict cookie.
  4. For each download, the API writes a grant intent record to the audit archive in both regions. Then one transaction counts the download, creates the grant and commits the audit record.
  5. The browser navigates to the content URL with its cookie. The API streams the scanned object version from S3, with Range support for resume, and re-checks state every 30 seconds. A revocation stops a running download within 60 seconds.

Why stream through the API. It is the only option that meets the download limit, resume, verification and fast revocation together in every supported browser. A pre-signed S3 URL outlives a revocation; CloudFront signed cookies would need a chunked-download engine in the browser. The cost: API pods carry file bytes, and the load balancer needs about $243 a month more capacity at design load.

Outbox and queues

Work for the workers is written to an outbox table in the same transaction as the change that causes it (ADR-0013). The relay claims rows with FOR UPDATE SKIP LOCKED, publishes them to SQS and marks them sent. A crash between the two loses nothing.

  • Queues: SQS queues for small-file scans, large-file scans and notifications, each with a dead-letter queue. Scan queues are fair queues keyed by tenant.
  • Idempotent consumers. Delivery is at least once, so each consumer records the outbox row ID it processed and skips duplicates.
  • IDs only. Messages never carry link tokens or codes.
  • Deferred work is a new delayed message, never a visibility change, which would push healthy work into the dead-letter queue.

A queue in PostgreSQL was rejected because polling and long-held leases would load the system of record.

Tenancy and the web app

Multi-tenancy

All tenants share one PostgreSQL schema, with tenant_id on every tenant-owned row (ADR-0003). Isolation is layered:

  • every query runs with an explicit scope, and a query without one is refused;
  • composite foreign keys that include tenant_id stop cross-tenant references;
  • row-level security on every tenant-owned table is a second layer, with the tenant set per transaction so it can't leak through a pooled connection (ADR-0020);
  • S3 object keys start with tenants/{tenant_id}/, and file names and messages are encrypted with a per-tenant data key.

A schema per tenant would mean 500 migrations per release at the design load. The isolation suite tests every endpoint with a second tenant's IDs instead.

Web app on S3 and CloudFront

One CloudFront distribution serves the React app from a private S3 bucket in Tokyo, with an Osaka copy as the failover origin (ADR-0008). The API has its own hostname on the load balancer. No CloudFront behavior routes to the API or the files buckets, so file contents never reach a CDN edge.

Decision records

ADRDecision
0002FastAPI, SQLAlchemy 2.0 and Alembic; workers share the service layer
0003Shared schema with tenant_id on every row
0004EKS with Karpenter and Argo CD
0005Terraform, GitHub Actions with OIDC, Argo CD
0006One AWS account per environment, plus a shared account
0007ECR in the shared account; signed images deployed by digest
0008Web app on S3 and CloudFront
0009OpenTelemetry with self-hosted Prometheus, Grafana, Loki and Tempo
0011Aurora PostgreSQL global database with a secondary in Osaka
0012S3 with versioning and replication with RTC
0013SQS with dead-letter queues, fed by a transactional outbox
0016Cognito for member sign-in, with mandatory MFA and tenant SSO
0017One KMS key per data class; per-tenant data keys for names and messages
0018Downloads streamed through the API
0019ClamAV up to 2 GB, GuardDuty Malware Protection above
0020Row-level security as a second isolation layer

Further ADRs cover rate-limit counters (0014), email (0015), network egress (0021) and the DLP engine (0022).

Source: Architecture, ADRs, PRD