Files
clean-architecture-backend-…/docs/fileserver/configuration.md
T
DongHyeonkaandClaude Opus 5 5f10b791d3 chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:48:43 +09:00

9.3 KiB

Fileserver configuration

Every key below lives under app.fileserver-platform (environment form APP_FILESERVER_PLATFORM_*). That namespace is the HTTP platform's alone: app.fileserver.* belongs to the R2 tabular publication capability and app.file-export.* to the R1 CSV export, and the three are deliberately separate so switching one on cannot switch on another.

While app.fileserver-platform.enabled is false none of these keys is bound at all — the auto-configuration that binds them is not processed — so a malformed value in a block nobody enabled cannot fail a startup. Once enabled, binding is strict: an unknown key under the prefix is refused rather than ignored. The defaults are the conservative ones: the capability is off, the admin plane is off, background reclamation is off, and there is no permissive authorization fallback. Turning the capability on is a deliberate act, and so is every surface it exposes.

Minimum to start

ca-skeleton:
  fileserver:
    enabled: true
    instance-id: ${HOSTNAME}                       # writer-lease owner; must be unique per node
    storage:
      root: /var/lib/backend/files                 # absolute, outside any webroot or config dir
    security:
      access-policy: role-based                    # or supply your own FileAccessPolicy bean
    observability:
      fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}

Startup fails, rather than degrading, when any of these is missing or unsafe:

Condition Why it is fatal
security.access-policy left at required with no FileAccessPolicy bean a file capability that authorizes by default is worse than one that refuses to start
observability.fingerprint-key unset while metrics are on an unkeyed digest of an enumerable identifier is reversible
the storage root fails a mandatory capability probe a volume that cannot create atomically, keep staging and content on one FileStore, or refuse symlinks is unsafe, not degraded
storage.publish-mode: atomic-move-required on a volume where the probe could not prove an atomic move the configured guarantee cannot be delivered
security.access-policy: unenforced under a prod profile a value that was convenient in development must not survive promotion

Authorization — security

Key Default Meaning
access-policy required required (supply your own bean), role-based, or unenforced
read-roles ROLE_FILE_READ grants metadata read and download
write-roles ROLE_FILE_WRITE grants create, append, finalize, delete, copy, move
admin-roles ROLE_FILE_ADMIN grants reverify and force-delete, and gates /internal/fileserver/** at the servlet chain

There is no anonymous-read switch. Every Fileserver route is authenticated by the servlet chain before any application policy is consulted, so such a setting could only ever have described a permission the transport had already refused — a configuration that reads as if it grants access and does not.

The three tiers do not inherit. An admin role cannot delete through the data plane, and a write role cannot reach the management plane — a role model where "can delete" implied "can force-delete" would make the audited plane reachable through the unaudited one.

unenforced authorizes everything and exists so a developer can exercise upload and download before deciding on a role model. It is refused under a production profile.

Storage — storage

Key Default Meaning
root /var/lib/backend/files absolute path; the only place a path exists
publish-mode atomic-move-preferred atomic-move-required, atomic-move-preferred, metadata-pointer
buffer-size 128KB bounds every transfer allocation; memory never scales with file size
forbidden-root-ancestors /app,/etc,/usr/share/nginx/html roots the storage root must not live under (webroot, config dirs)

root must be absolute. A relative root resolves against the process working directory, which is one path in a container and another in a test, so it is refused at binding time.

Three former keys are gone, pinned as constants instead: staging and content share one FileStore, symbolic links are never followed, and the object and its directory are synced before READY. Each is an invariant the atomic publish and the namespace boundary are built on — a deployment that could switch one off would be running a different capability under the same name and the same tests.

The storage provider has no selector either. There is exactly one implementation, and a type key with one legal value is a promise of pluggability that nothing keeps.

Upload, download, transfer

Key Default Meaning
upload.max-file-size 100MB hard ceiling; also drives spring.servlet.multipart.max-file-size
upload.max-request-size 110MB request envelope; must be at least max-file-size
upload.initial-reservation 8MB quota reserved when the length is unknown
upload.ttl 1h how long a resumable upload stays claimable
upload.reservation-ttl 24h how long an unsettled quota reservation survives
upload.lease-duration 30s writer lease; renewed at one third of this
upload.max-parts 16 multipart part ceiling
upload.require-content-length false refuse chunked raw uploads
download.cache-control private, no-store emitted on every content response
download.inline-allowed false scriptable content is always an attachment regardless
download.max-ranges 1 multi-range responses are opt-in
download.max-range-bytes 100MB total bytes one ranged response may cover
download.zero-copy-enabled true hand large plaintext responses to the kernel
download.zero-copy-minimum-bytes 16MB below this the syscall setup costs more than it saves
transfer.core-size / max-size / queue-capacity 8 / 32 / 64 blocking transfer pool bounds
transfer.await-seconds 300 how long a transfer may occupy a pool thread

Zero copy changes no header and no status. When storage declines it — an unreadable region, an unsupported backend — the response is streamed instead and is byte-identical.

Verification — verification

Key Default Meaning
timeout 5s per-verifier ceiling
require-media-type-verdict false refuse a file whose type could not be determined
inline-safe-profile false accept scriptable content instead of quarantining it

Set inline-safe-profile: true only when downloads are never served inline from a trusted origin.

Quota and admission — quota

Key Default Meaning
instance-upload-permits 16 concurrent uploads this node admits
scope-upload-permits 4 concurrent uploads one namespace admits
direct-download-permits 64 concurrent non-delegated downloads
soft-high-water 0.70 storage fraction at which pressure is reported
hard-high-water 0.85 storage fraction at which uploads are refused

When the storage fraction cannot be read, admission treats it as unknown and does not apply the high-water rule — a synthetic 0 would silently disable the guard, and a synthetic 1 would take the capability down over a failed syscall.

Background reclamation — cleanup

Key Default Meaning
enabled false run the cleanup worker on this node
interval 60s fixed delay between batches, not fixed rate
max-items 100 items one batch may claim
max-bytes 1GB bytes one batch may reclaim
retry-backoff 5m how long a failed item waits before it is due again

The worker deletes physical objects, so it is off until a deployment decides otherwise. A node without it still queues cleanup items; another node or an operator reclaims them. An item that fails eight times is abandoned rather than retried forever — it stays visible to an operator, parked rather than discarded.

Management plane — admin

Key Default Meaning
enabled false expose /internal/fileserver/**
orphan-minimum-age 1h how long an unreferenced object must exist before a scan may name it

Publishing content and committing its record are two steps. Anything younger than orphan-minimum-age is assumed to be mid-commit rather than abandoned; shortening this makes concurrent uploads look like orphans.

Front-proxy delegation — nginx

Key Default Meaning
enabled false emit X-Accel-Redirect instead of a body
internal-prefix /__files/ must be an internal location resolving to the content root
object-suffix .bin layout suffix the proxy appends
minimum-size 16MB below this the application serves the transfer itself

Delegation is decided only after authorization and the READY gate, so an internal redirect can only ever name content the caller was already allowed to read.

Protocols — tus

Key Default Meaning
enabled false expose the tus 1.0 endpoints

The HTTPbis resumable-upload draft-12 surface is experimental and documented in support-matrix.md.