Files
CairnCMS exposes files through two parallel surfaces:
/files— the metadata API. CRUD over thedirectus_filessystem collection: create rows, list and search files, update titles and folder assignments, delete records. Same shape as items endpoints, with multipart upload as the addition./assets/<id>— the bytes API. Streams the actual file content, with optional on-the-fly image transformations, range support, and content-negotiated format selection.
The two are separate by design: client applications fetch metadata once and then build asset URLs against /assets/<id> for the bytes. Caching, content delivery, and transform behavior are properties of the asset surface; everything else is on /files.
The file record
Section titled “The file record”Every uploaded file gets a row in directus_files with metadata describing it:
id(UUID) — primary key, used in/assets/<id>.storage— name of the storage location holding the bytes (matches an entry inSTORAGE_LOCATIONS).filename_disk— the filename in the storage backend.filename_download— the filename the platform suggests when serving the file as an attachment.title,description,tags,folder— operator-managed metadata.type— the file’s MIME type, set from the upload’sContent-Type.filesize,width,height,duration,metadata— derived properties extracted at upload time.uploaded_by,uploaded_on,modified_by,modified_on— accountability fields.
filename_disk and uploaded_by are server-controlled. filename_disk is derived from the file’s primary key when the bytes are written to storage, and uploaded_by is set from the authenticated principal. Attempts to set either field through POST /files, PATCH /files, or POST /files/import are silently stripped from the inbound payload before the row is written; the request still succeeds, but the supplied values do not reach the database. The remaining accountability fields (uploaded_on, modified_by, modified_on) are platform-managed in the normal way (set on insert and update via the existing accountability machinery) and behave the same as on user collections.
The platform does not prevent you from adding fields to directus_files through the normal field-creation surfaces. The system collection accepts custom fields the same way user collections do. That said, the cleaner pattern for project-specific metadata is usually a related user collection that references directus_files rather than columns added directly to the system collection. Custom fields on directus_files migrate, snapshot, and apply through schema-as-code, but they tend to entangle the system collection with project-specific shape in ways that are harder to maintain.
Upload a file
Section titled “Upload a file”POST /filesContent-Type: multipart/form-data; boundary=...
------boundaryContent-Disposition: form-data; name="title"
A picture of a cat------boundaryContent-Disposition: form-data; name="folder"
af8c7b6e-1234-5678-9abc-def012345678------boundaryContent-Disposition: form-data; name="file"; filename="cat.jpg"Content-Type: image/jpeg
<binary bytes>------boundary--The metadata fields come first, then the file part. Order matters because the platform creates the directus_files row as soon as the file part begins streaming, so any fields after the file part are not picked up. Common fields:
title— defaults to a humanized version of the upload filename if omitted.description,tags,folder— set the corresponding metadata.storage— the storage location. Defaults to the first entry inSTORAGE_LOCATIONS.
Multiple files in one multipart body are supported — each file part creates one row. Per-file metadata applies to the next file part to follow it (the platform clears the metadata buffer after each file).
The response is the created file record (or array of records when more than one was uploaded), re-read through any fields and deep query parameters set on the request:
{ "data": { "id": "f7a8e1d2-3456-7890-abcd-ef0123456789", "title": "A picture of a cat", "filename_download": "cat.jpg", "type": "image/jpeg", "filesize": 245132, "width": 1920, "height": 1080 }}For a metadata-only row (no bytes which is useful for importing references to externally-stored files), POST a JSON body instead of multipart. The platform creates the row but does not write to storage. The JSON path requires at least type (the MIME type) on the body; without it, the request fails with INVALID_PAYLOAD. This shape is rarely the right one; see POST /files/import below for the common case of pulling a file from a URL.
Import from a URL
Section titled “Import from a URL”When the file already lives on the public internet, the import endpoint pulls it down server-side:
POST /files/importContent-Type: application/json
{ "url": "https://example.com/cat.jpg", "data": { "title": "A cat from the internet", "folder": "af8c7b6e-1234-5678-9abc-def012345678" }}url is required and must be an absolute HTTP/HTTPS URL. data is an optional object of metadata fields applied to the resulting directus_files row. The platform fetches the URL, stores the bytes in the configured storage location, and returns the new file record.
URL imports are subject to outbound IP validation. By default, loopback ranges, the host’s own network interfaces, and the EC2/cloud metadata endpoint at 169.254.169.254 are blocked to prevent server-side request forgery against internal services. Imports that resolve to a denied IP fail at the outbound connection step and return 503 SERVICE_UNAVAILABLE with a body indicating the import URL could not be fetched. Operators can extend the deny list through the IMPORT_IP_DENY_LIST environment variable; see Configuration for the exact behavior, including the special meaning of 0.0.0.0.
This endpoint is convenient for migrating asset references from another system, where re-uploading every file from the client would be slower than letting the server pull them in parallel.
List and read file metadata
Section titled “List and read file metadata”The list and read shapes match the items API:
GET /files?fields=id,title,type,filesize&filter[type][_starts_with]=image/&sort=-uploaded_onGET /files/<id>?fields=id,title,description,uploaded_by.first_nameSEARCH /files works the same way SEARCH /items/<collection> does. Query in the body, with optional keys for fetching a specific set of file IDs. See Items for the shared semantics.
Update file metadata
Section titled “Update file metadata”Single record:
PATCH /files/<id>Content-Type: application/json
{ "title": "A new title", "folder": "<folder-id>" }Batch:
PATCH /filesContent-Type: application/json
{ "keys": ["<id-1>", "<id-2>"], "data": { "folder": "<folder-id>" } }The same three body shapes that work for PATCH /items/<collection> apply: array of records (each with id), { keys, data }, or { query, data }. See Items / Update many items for the full reference.
Replace file bytes
Section titled “Replace file bytes”PATCH /files/<id> also accepts multipart bodies, in which case the bytes for the existing record are replaced:
PATCH /files/<id>Content-Type: multipart/form-data; boundary=...
------boundaryContent-Disposition: form-data; name="title"
Updated title------boundaryContent-Disposition: form-data; name="file"; filename="updated.jpg"Content-Type: image/jpeg
<new bytes>------boundary--The file ID stays the same, so any references in your data model continue to resolve. The new bytes get a fresh filename_disk; the existing storage object is replaced. Cached responses and downstream CDNs may need an explicit purge to reflect the new content.
Delete files
Section titled “Delete files”DELETE /files/<id>Deletes both the directus_files row and the underlying bytes from the storage backend.
Batch deletes accept the same three body shapes as items:
DELETE /filesContent-Type: application/json
["<id-1>", "<id-2>", "<id-3>"]Deleting a file that is referenced by another row in the data model defaults to clearing the reference (SET NULL). For collections where the file should not silently disappear, set the relation’s On Delete to RESTRICT so the delete is blocked while references exist. See Files for the operator-side configuration.
Serve an asset
Section titled “Serve an asset”GET /assets/<id>GET /assets/<id>/<filename>HEAD /assets/<id>Streams the bytes for the file with the given ID. <filename> in the path is optional and ignored for resolution. It exists so URLs can carry a sensible filename for download tools and web crawlers without the client needing to look up filename_download first.
The default response is Content-Disposition: inline (the browser displays the file rather than offering it for download). Pass ?download to override:
GET /assets/<id>?downloadHEAD /assets/<id> returns the same headers without the body, useful for size checks and inspecting Content-Type, Content-Length, and Last-Modified before deciding whether to fetch.
Range requests are supported. A request with Range: bytes=0-1023 returns 206 Partial Content with the requested byte range; the response includes Content-Range and Content-Length matching the slice. This is what video players use for seek-without-redownload.
Cache-Control is set from ASSETS_CACHE_TTL (default 30d). Asset URLs are immutable in practice — replacing a file’s bytes via PATCH /files/<id> produces a new filename_disk but the asset URL stays the same, so cache invalidation has to come from the operator side.
Image transformations
Section titled “Image transformations”Image assets can be transformed on the fly through query parameters on /assets/<id>. Two ways to specify the transformation: a preset key, or a free-form transform query.
Preset keys
Section titled “Preset keys”The platform ships six built-in keys for common thumbnail sizes:
| Key | Operation |
|---|---|
system-small-cover | 64×64, cropped to cover. |
system-small-contain | width 64, scaled to fit. |
system-medium-cover | 300×300, cropped to cover. |
system-medium-contain | width 300, scaled to fit. |
system-large-cover | 800×800, cropped to cover. |
system-large-contain | width 800, scaled to fit. |
GET /assets/<id>?key=system-medium-coverOperators can define additional preset keys through Settings > Project Settings > Files & Storage (the storage_asset_presets setting). The same ?key=<name> shape works for project presets.
?key cannot be combined with any other transformation parameter. If you need a different transform than what a preset offers, drop the key and supply the parameters directly.
Free-form parameters
Section titled “Free-form parameters”Five direct parameters cover the common cases:
GET /assets/<id>?width=600&height=400&fit=cover&format=webp&quality=80width,height— output dimensions in pixels. Either or both can be set.fit— one ofcover,contain,inside,outside. Defaults tocoverwhen bothwidthandheightare set.format—jpg,png,webp,tiff,avif, orauto(see below).quality— output quality, 1-100. Encoder-specific; for JPEG and WebP, 80 is a reasonable default.withoutEnlargement— whentrue, prevents scaling images smaller than the target dimensions up.
Multi-step transforms
Section titled “Multi-step transforms”For chained operations beyond resize-and-format, the transforms parameter takes a JSON array of operation tuples:
GET /assets/<id>?transforms=[["resize",{"width":600,"fit":"inside"}],["blur",2],["grayscale"]]Each entry is [<operation>, <args>?]. The operation set covers the Sharp library’s image methods (resize, rotate, blur, sharpen, grayscale, modulate, and so on). The number of operations per request is bounded by ASSETS_TRANSFORM_MAX_OPERATIONS (default 5); the cap protects against pathological transform requests that would consume CPU on the server.
Format auto-negotiation
Section titled “Format auto-negotiation”format=auto selects the output format based on the request’s Accept header:
- AVIF if the client advertises
image/avif. - WebP if the client advertises
image/webpand not AVIF. - JPEG otherwise.
This is the right default for modern browsers. The response includes Vary: Accept so caches and CDNs return the right variant per client.
Project-level transform policy
Section titled “Project-level transform policy”The storage_asset_transform project setting controls which transformations are accepted:
all— any transformation parameters are accepted.presets— only the system keys and the configuredstorage_asset_presetskeys are accepted; arbitrarywidth,height, and so on are rejected.- Any other value — only the system keys work; project presets and arbitrary parameters are rejected.
The presets setting is the right hardening choice for public-facing deployments where you want to bound the set of asset variants the server is willing to compute.
Storage locations
Section titled “Storage locations”STORAGE_LOCATIONS lists the configured locations; each entry has its own STORAGE_<NAME>_* configuration block (see Configuration). The directus_files.storage field on each row records which location holds the bytes for that file.
When uploading without a storage field, the platform writes to the first entry in STORAGE_LOCATIONS. To upload to a non-default location, include storage=<name> as a multipart field before the file part.
Multi-location setups support per-file backend choice (some files on local disk, others in S3) without the client needing to know the storage details — /assets/<id> resolves through the row’s storage value automatically.
Permission semantics
Section titled “Permission semantics”File metadata permissions are role-driven the same way item permissions are. Permissions are checked against the directus_files system collection:
- Read — what fields and rows the role can list and fetch through
/files. Asset access through/assets/<id>checks the same read permission against the file’s row. - Create — required for upload. Roles without create permission on
directus_filesget403onPOST /filesandPOST /files/import. - Update — required for metadata edits and bytes replacement.
- Delete — required for
DELETE /files.
Same-origin authenticated browser clients can fetch protected assets without attaching a token. The refresh-token cookie set during login is used to look up the session and authorize the asset request, which is the path the first-party admin app uses for protected asset loads. Cross-origin clients and server-to-server callers still need an Authorization: Bearer header (the ?access_token= query parameter is also supported for compatibility, but not preferred — see Authentication / Attaching a token).
For asset URLs that should be reachable without a token, configure read permission for the Public role on directus_files. Be specific about the filter — granting read all to Public exposes every file the platform has stored.
GraphQL
Section titled “GraphQL”directus_files is part of the system schema, so file metadata operations live on /graphql/system:
query { files(filter: { type: { _starts_with: "image/" } }, limit: 20) { id title width height }
files_by_id(id: "<id>") { id title description }}
mutation { update_files_item(id: "<id>", data: { title: "Updated" }) { id title }
delete_files_item(id: "<id>") { id }}Upload itself is a multipart-file operation that does not fit GraphQL’s single-document model. Use POST /files (multipart) or POST /files/import (JSON) for the actual upload, then use GraphQL for any subsequent metadata manipulation.
The asset surface (/assets/<id>) is REST-only. There is no equivalent in GraphQL, since GraphQL fields are not designed to return raw bytes.
Where to go next
Section titled “Where to go next”- Items — the shared CRUD shape that
/filesfollows. - Filters and queries — the query DSL used to filter files in
/fileslistings. - Files — the operator-side configuration of folders, presets, and storage backends.
- Configuration —
STORAGE_LOCATIONS,ASSETS_*variables, and per-driver settings.