Skip to content

File storage

Files uploaded through file-upload questions never live in MongoDB — they go to a pluggable storage backend behind one interface, and a Mongo uploaded_files collection is the index. Two backends ship:

STORAGE_MODE=filesystem | s3 # default: filesystem (bare runs); the compose stack sets s3 + MinIO

Any other value fails startup with an explicit error — there is no silent fallback.

Whichever mode you pick, the backend writes a small canary object at startup, reads it back, compares the bytes, and deletes it. If any part of that round-trip fails, the backend refuses to start and the error names the settings to check. Storage misconfiguration surfaces at deploy time, not at your first respondent’s upload.

STORAGE_MODE=filesystem
FS_STORAGE_PATH=./data/uploads # created if absent; startup fails loudly if unwritable

Simplest possible setup: files land in a directory. The path is created if missing; writes are atomic (temp file + rename). Two things to know:

  • In the Docker image the relative default resolves to /app/data/uploads inside the container — mount a volume there (or set FS_STORAGE_PATH to a mounted path) or files disappear when the container is replaced.
  • Storage keys are always generated server-side (questionnaireId/responseId/fileId) — no user-supplied string ever becomes part of a path.
STORAGE_MODE=s3
S3_ENDPOINT=http://minio:9000 # omit/empty for real AWS (SDK derives from region)
S3_BUCKET=forms-engine-uploads # must already exist — the backend never creates it
S3_ACCESS_KEY=… # empty = AWS SDK default credential chain
S3_SECRET_KEY=…
S3_REGION=us-east-1
S3_PATH_STYLE=true # ⚠️ true for MinIO and most S3-compatibles; false/omit for AWS

Smoke-tested: MinIO (the compose stack and the test suite) and AWS S3. Expected-compatible per protocol: R2, B2, DigitalOcean Spaces, GCS interoperability mode.

The bundled compose stack uses MinIO with a one-shot minio-init container that creates the bucket, so docker compose up remains the whole setup. If you bring your own bucket, create it yourself first — the canary will catch a missing one at startup.

The editor offers upload categories; the server owns the extension ↔ content mapping and verifies file content (magic-byte signatures), never trusting the filename or the client’s Content-Type:

Category Extensions
Documents pdf, doc, docx
Images jpg, jpeg, png, gif, webp
Spreadsheets xls, xlsx, csv
Text txt, md
Archives zip

Executables, scripts, HTML, and SVG are not offerable in any category, ever (SVG is scriptable, and therefore an attack format rather than an image, for upload purposes). A .pdf that is actually a zip, or an “image” that is actually HTML, is rejected with a specific error message. Downloads exist only on the management API, always served as Content-Disposition: attachment with X-Content-Type-Options: nosniff — never inline, never from a public endpoint.

All env-tunable — see Environment variables:

  • Per-file size comes from the question’s configuration, bounded by MAX_FILE_SIZE_MB (default 50).
  • Per-response: MAX_FILES_PER_RESPONSE (20 files) and MAX_BYTES_PER_RESPONSE_MB (200 MB).
  • Upload rate limit: ≈30 uploads / 10 minutes / IP.

An hourly cleanup job (FILE_CLEANUP_CRON) deletes active files older than FILE_ORPHAN_GRACE_HOURS (24) that no response’s answers reference — covering abandoned sessions and failed deletes. Files referenced by a completed response are permanent. Deleted files leave a tombstone row in the index (status DELETED) rather than vanishing from it.

A no-op FileScanner interface is invoked after content verification and before storage (backend/src/main/java/io/formsengine/service/FileScanner.java):

void scan(String fileName, String contentType, InputStream content);

Return normally to accept; throw to veto the upload. contentType is the verified type, never the client’s claim. If you need virus scanning, implement it against ClamAV (e.g. clamd’s INSTREAM protocol) and register your bean — nothing else changes.

One operational caveat: a storage outage during an upload surfaces to the client as a generic 500, not the API’s usual structured error body. The startup canary makes this rare, but it’s worth knowing when reading logs.