A serverless pipeline that accepts file uploads, scans them for malware with GuardDuty, checks their real type and size, and sorts them into an Approved or Quarantine bucket. It's built entirely in Terraform by a team of four, one reviewed pull request at a time, and anyone can deploy it into their own AWS account.
New to the project? Start with the project overview: where the design came from, what changed and why, how the work is split, the tools, and how the check works. Each portion's details are in docs/portions/.
Solid arrows are the main flow, numbered in order. Dotted arrows are return and failure paths. The letter on each box is the portion of the team that owns it.
flowchart TD
client(["Client"])
subgraph aws["AWS Cloud (one Region) · letters = owning portion"]
apigw["API Gateway · HTTP API (B)<br/>POST /upload-url<br/>GET /status/{upload_id}"]
l1["Lambda 1 · upload_url (B)<br/>check request, write PENDING,<br/>sign presigned POST"]
status["Status Lambda (A)<br/>returns PENDING, APPROVED,<br/>QUARANTINED or EXPIRED"]
ddb[("DynamoDB (A)<br/>one row per upload_id")]
incoming[("S3 Incoming (A)<br/>versioned · uploads/ prefix")]
gd["GuardDuty Malware<br/>Protection for S3 (C)"]
eb["EventBridge<br/>default bus (C)"]
dlq[["SQS target DLQ (C)"]]
sns["SNS email alerts (C)"]
l2["Lambda 2 · router (D)<br/>scan result + magic bytes + size<br/>copy and delete by versionId"]
fail[["SQS router-failures (D)"]]
approved[("S3 Approved (A)")]
quarantine[("S3 Quarantine (A)")]
end
client -->|"1 · POST /upload-url"| apigw
apigw --> l1
l1 -->|"2 · PENDING row"| ddb
l1 -.->|"presigned POST"| client
client -->|"3 · upload to uploads/{upload_id}/"| incoming
incoming -->|"4 · scan new version"| gd
gd -->|"5 · Object Scan Result"| eb
eb -->|"6 · scan-result rule"| l2
eb -.->|"delivery failed"| dlq
eb -->|"THREATS_FOUND or plan error"| sns
l2 -->|"7 · clean and valid"| approved
l2 -->|"7 · threat, skipped, failed, invalid"| quarantine
l2 -.->|"delete scanned version"| incoming
l2 -->|"conditional status update"| ddb
l2 -.->|"function error"| fail
client -->|"8 · GET /status"| apigw
apigw --> status
status -->|"read by key"| ddb
classDef s3 fill:#7AA116,stroke:#4d6b0c,color:#fff
classDef lambda fill:#ED7100,stroke:#9c4a00,color:#fff
classDef db fill:#C925D1,stroke:#7d1783,color:#fff
classDef events fill:#E7157B,stroke:#9a0e52,color:#fff
classDef security fill:#DD344C,stroke:#8f1f30,color:#fff
classDef queue fill:#5a6b86,stroke:#2f3a4d,color:#fff
class incoming,approved,quarantine s3
class l1,l2,status lambda
class ddb db
class apigw,eb,sns events
class gd security
class dlq,fail queue
- The client asks the API for an upload URL, giving the file's name, type, and size.
- Lambda 1 checks the request, records it as
PENDINGin DynamoDB, and returns a presigned POST locked touploads/{upload_id}/{filename}. - The client uploads straight to the Incoming bucket. The file never passes through API Gateway or Lambda.
- GuardDuty Malware Protection for S3 scans the new object.
- GuardDuty publishes the scan result to EventBridge.
- An EventBridge rule invokes Lambda 2.
- Lambda 2 checks the scan result, the file's magic bytes, and its real size. It then copies the exact scanned version to Approved or Quarantine, deletes it from Incoming, and updates DynamoDB.
- The client polls
GET /status/{upload_id}for the result.
| Portion | Owns | Milestones |
|---|---|---|
| A: Storage, Data, and Status | S3 buckets, DynamoDB, bucket policies, status endpoint | A1, A2, A3, A4 |
| B: Upload API and Observability | API Gateway, Lambda 1, dashboard and alarms | B1, B2, B3 |
| C: Scanning and Events | GuardDuty, EventBridge, DLQ, alerts | C1, C2, C3 |
| D: Processing | Lambda 2 (validation and routing), failure queue, redrive | D1, D2, D3, D4 |
M0 (repo foundation) and INT (integration) are shared milestones with one owner each. SHIP (ship) is the lead's: it removes the build scaffolding and rewrites the README for a reader.
- One branch per milestone, named after it:
a1-buckets,b2-api, and so on. Never commit tomaindirectly. It's protected and only accepts reviewed PRs. - Read your brief first. Every milestone has one in
docs/milestones/. It names the resources, variables, and settings to build, and links the docs. It doesn't give you the code. - Push to check. Every push runs the check on GitHub: formatting, validation, your milestone's spec tests (taken from the branch name, so
a1-bucketschecks A1), and every milestone already merged. Results show on the commit, the PR, and the run's summary page under Actions. You want👍. The tests use a mocked AWS provider, so they touch no AWS account and cost nothing. - Re-run on demand.
scripts/remote-check.sh(orscripts/remote-check.sh A1) re-runs the check on GitHub for your pushed branch and streams the result to your terminal. It uses the GitHub CLI; the raw command isgh workflow run ci.yml --ref <branch> -f milestone=A1, thengh run watch. - Register your milestone. Add its ID to
milestones.txtin the same PR. CI re-checks every listed milestone on every PR, so nobody can break someone else's merged work without CI catching it. - Prove it in your account. Each brief ends with a "Done when" step you run against real AWS. Paste its output into the PR.
- One approval to merge, from the lead. Every PR edits
milestones.txt, which CODEOWNERS gives to the lead, and the lead's own PRs merge with the admin override. Review means reading the diff against the brief, not only seeing a green check. - The test harness is lead-owned.
tests/,scripts/,milestones.txt, and.github/(which holds the check itself) are covered by CODEOWNERS, so changes there need the lead's review. A syntax error in any test file breaks every milestone's check, which is one reason these files are protected.
Accept the collaborator invitation from your email, then:
git clone https://github.com/umbra-codex/aws-secure-file-upload.git
cd aws-secure-file-uploadEvery command in this README and in the milestone briefs runs from this folder (the repo root).
- Terraform 1.10 or newer (CI uses 1.16.6). You need it for each milestone's "Done when" step, which deploys to your own account.
- The GitHub CLI, signed in with
gh auth login, for on-demand checks. - AWS CLI v2, signed in to your own AWS account (
aws sts get-caller-identityshould print your account). - An AWS Budget alert (for example, $5) on that account before your first
apply.
Terraform keeps its state in an S3 bucket that you create by hand, once:
PROJECT=secure-upload # must match project_name in your terraform.tfvars
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
BUCKET="${PROJECT}-${ACCOUNT}-tfstate"
aws s3api create-bucket --bucket "$BUCKET" --region us-east-1
aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
echo "$BUCKET"In any region other than us-east-1, add --create-bucket-configuration LocationConstraint=<region> to create-bucket.
scripts/bootstrap-state.sh runs steps 3 and 4 for you: it reads the account from AWS, creates the bucket if it is missing, enables versioning and blocks public access, writes backend.hcl, copies terraform.tfvars, and fills owner from gh. Safe to re-run, and nothing lives in a shell variable, so a new terminal costs nothing.
cp terraform.tfvars.example terraform.tfvars # fill in project_name, owner, alert_email
cp backend.hcl.example backend.hcl # set bucket to the name printed above
terraform init -backend-config=backend.hcl
terraform plan # should plan with no errorsBoth copied files are gitignored, so they never reach the repo.
Each brief lists the milestones it depends on. Every brief is already in docs/milestones/.
| ID | What | Brief |
|---|---|---|
| M0 | Create the GitHub repo (lead only) | M0.md |
| A1 | The bucket module, called three times | A1.md |
| A2 | Expiration rules and the uploads table | A2.md |
| A3 | Bucket policies | A3.md |
| A4 | The status endpoint | A4.md |
| B1 | Lambda 1 and its role | B1.md |
| B2 | HTTP API and the upload size limit | B2.md |
| B3 | Throttling, CORS, alarms, dashboard | B3.md |
| C1 | GuardDuty Malware Protection plan | C1.md |
| C2 | Scan-result rule and DLQ | C2.md |
| C3 | Alerts | C3.md |
| D1 | Lambda 2 and its role | D1.md |
| D2 | Routing, failure queue, redrive | D2.md |
| D3 | File-type and size validation | D3.md |
| D4 | Idempotency and right-sizing | D4.md |
| INT | The last lint rule, the runbook, and the end-to-end test | INT.md |
| SHIP | Ship the repository: cleanup, the README for a stranger, the normal workflow | SHIP.md |
Everything here is pay-per-use, and an idle stack costs close to nothing. GuardDuty Malware Protection bills per GB scanned and per object evaluated, so test with a few small files. When you're done testing for the day:
terraform destroyYour state bucket stays. Delete it by hand only when the project is over.
S3 bucket names are global, so <project>-<account_id>-<role> keeps four teammates' deployments from colliding. An AWS account ID is an identifier, not a credential; AWS doesn't treat it as secret, and it can't be used to sign in.
Every service here (S3, Lambda, API Gateway, DynamoDB, EventBridge, GuardDuty, SQS, SNS) is a managed AWS endpoint reached over IAM-authenticated HTTPS. Nothing needs a private network, so a VPC would add cost and complexity without adding security.
.
├── *.tf root module: one file per portion wires its module in
├── modules/ storage, status (A) · upload_api (B) · scanning (C) · processing (D)
├── modules/*/src/ Lambda source code, inside the module that deploys it (A4, B1, D1)
├── tests/ one spec test per milestone (*.tftest.hcl)
├── scripts/ remote-check.sh: re-run the CI check on demand
├── .github/ CI workflow and the check it runs (lead-owned)
├── milestones.txt milestones merged so far (CI checks all of them)
└── docs/ overview.md, portions/ (one doc per portion), milestones/ (one brief per milestone)
.gitignore keeps .terraform.lock.hcl out of git, so the lock file tofu init writes stays on your machine. Pull requests still have to pass the Terraform check in CI.