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).
| Container | Job |
|---|---|
| API service | Every HTTP call: sign-in, sessions, uploads, verification, download grants and the download stream, revocation. At least 3 pods across 3 AZs |
| Outbox relay | Publishes outbox rows from PostgreSQL to SQS |
| Scan worker | Streams each file to ClamAV and the DLP inspector in one pass and records the results |
| ClamAV and DLP inspector | Their own pods with no credentials; the inspector has no network access at all |
| Notification worker | Creates link tokens and codes, sends every email through SES |
| Lifecycle jobs | Expiry, deletion in both regions, reconciliation, audit archiving. The only workload that deletes objects |
| Operator API | Tenant 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.
- 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.
- 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.
- 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. - 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).
- 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
sentand 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).
- 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. - The recipient asks for a code. After per-IP, per-link and per-address limits, a 6-digit code goes to the invited address.
- The recipient submits token and code. The API checks attempts, lockout and expiry, then opens a link session in a
SameSite=Strictcookie. - 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.
- 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_idstop 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
| ADR | Decision |
|---|---|
| 0002 | FastAPI, SQLAlchemy 2.0 and Alembic; workers share the service layer |
| 0003 | Shared schema with tenant_id on every row |
| 0004 | EKS with Karpenter and Argo CD |
| 0005 | Terraform, GitHub Actions with OIDC, Argo CD |
| 0006 | One AWS account per environment, plus a shared account |
| 0007 | ECR in the shared account; signed images deployed by digest |
| 0008 | Web app on S3 and CloudFront |
| 0009 | OpenTelemetry with self-hosted Prometheus, Grafana, Loki and Tempo |
| 0011 | Aurora PostgreSQL global database with a secondary in Osaka |
| 0012 | S3 with versioning and replication with RTC |
| 0013 | SQS with dead-letter queues, fed by a transactional outbox |
| 0016 | Cognito for member sign-in, with mandatory MFA and tenant SSO |
| 0017 | One KMS key per data class; per-tenant data keys for names and messages |
| 0018 | Downloads streamed through the API |
| 0019 | ClamAV up to 2 GB, GuardDuty Malware Protection above |
| 0020 | Row-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