Skip to content

Storage

filex doesn't store files itself — it mounts one or more storage backends and presents them as a unified tree. Each mounted storage shows up as a top‑level folder you name. You can mix several at once (e.g. a local disk, an S3 bucket, and an SFTP server side by side).

Supported adapters: local filesystem · S3 / S3‑compatible · SFTP · WebDAV · FTP/FTPS · SMB/CIFS.

That list is not the limit: a backend filex does not ship can be added as a storage plugin — a separate program that describes its own config form and appears here as plugin:<driver>, behaving like any adapter below. A plugin only gets that far by proving what it claims: filex probes every capability it declares before registering it, and probes it again against the configuration you type when you save a storage on it, so a driver that half works is refused rather than offered.

A NAS (Synology, QNAP, TrueNAS, a Windows share…) is supported two ways: the smb driver talks to the share directly, and for NFS you mount it with the operating system and serve the mount point with the local adapter. Most boxes also speak SFTP/FTP/WebDAV natively. See NAS.

Reaching filex from somewhere else — as an S3 endpoint, an SFTP server, an FTPS server, an NFS export or a mounted drive — is the other direction, and lives in PROTOCOLS.md.


How storages work

A storage is a row in filex's database, not an environment variable. It records the adapter (driver), a per‑adapter config blob (bucket, host, credentials, …), a mount name, and options like read‑only and sync cadence.

filex keeps a DB cache of the file tree so listings are fast (a few ms) instead of hitting the backend every time. A background sync worker keeps that cache in step with the real backend (see Sync).

Root‑path guard. A storage must point at a sub‑folder / prefix, never the bucket or filesystem root. This stops filex from ever shadowing pre‑existing objects at the root. See Path validation.

How full a storage is, is not an admin-only number.GET /api/files/quota/storages answers {name, used_bytes, file_count} for each storage the caller can open — filtered by the same grant check the explorer's own root listing applies, so the set of drives somebody can measure is exactly the set they can browse. It is what Home's storage cards print for a non-admin. The figure is the drive's total, not that person's share of it (/api/files/quota/me is the per-user sum, and printing that under a drive's name would be a number about the person wearing a label about the drive), and a count is reused for 15 seconds so a page of cards costs one table scan rather than one per card.


Adding a storage

Three equivalent ways. The admin UI is recommended — it validates the path and offers a "Test connection" before saving.

Admin UI

Sign in as an admin → Storages → Add. Pick a driver, fill in the config fields, click Test connection, then Save. The first sync starts automatically.

Editing a storage afterwards

Saving takes effect on the running process: the cached driver is dropped, the syncer is stopped and a new one is started from the row you just wrote. No restart, and no stale connection left holding the previous credentials.

Renaming a storage changes its address on every file protocol. The name is the first path segment:

ProtocolAddress
WebDAV/dav/<storage name>/<path>
SFTP, FTPS, NFS/<storage name>/<path>
S3-compatible APIthe bucket is <storage name>

So a mount, a bookmark or a script that used the old name answers 404 until it is updated. The edit form warns while the name field differs from what is saved. Nothing inside filex breaks — shares, permissions and the catalogue are keyed by id, not by name.

Use the stable address for anything automated. Every storage also carries a uid, assigned once when it is created and never changed, and every protocol accepts it in place of the name:

/dav/7f3a1b2c-4d5e-4f60-8a1b-2c3d4e5f6071/Documents/   WebDAV
/7f3a1b2c-4d5e-4f60-8a1b-2c3d4e5f6071/Documents/       SFTP, FTPS, NFS
s3://7f3a1b2c-4d5e-4f60-8a1b-2c3d4e5f6071/Documents/   the S3-compatible API

The rule lives in one place (internal/storageref) rather than in each server, so there is no protocol where only one of the two identifiers works.

It is on the storage's page in the admin UI, under Stable address, and in GET /api/admin/storages as uid. A mount written against it survives every rename. The name stays the address people type; the uid is the address a machine should be given.

Admin API

POST /api/admin/storages (admin session/token). Body is the storage config; config holds the per‑adapter map:

bash
curl -X POST https://files.example.com/api/admin/storages \
  -H 'Content-Type: application/json' -b cookies.txt \
  -d '{
    "name": "team-bucket",
    "driver": "s3",
    "mount_path": "/",
    "config": { "bucket": "my-bucket", "prefix": "filex", "region": "auto",
                "endpoint": "https://s3.example.com", "path_style": true,
                "access_key": "…", "secret_key": "…" },
    "read_only": false,
    "enabled": true
  }'

Test credentials without saving first: POST /api/admin/storages/test with the same body → {ok, sample_listing, object_count} or {ok:false, error:"…"} (the driver's error, verbatim).

The probe is bounded at 10 seconds. That is a limit on the button, not on the driver: the S3 driver keeps its wide retry budget because a background sync run needs it, but an unreachable endpoint made this endpoint take 20-30 seconds to say so. A probe that runs out of time answers timed out after 10s — the endpoint did not answer: … with the driver's own error after it, so a slow endpoint reads differently from a wrong one.

CLI

Good for automation / first boot:

bash
filex storage add \
  --name team-bucket --driver s3 --mount / \
  --config '{"bucket":"my-bucket","prefix":"filex","region":"auto",
             "endpoint":"https://s3.example.com","path_style":true,
             "access_key":"…","secret_key":"…"}'

filex storage list
filex storage remove --name team-bucket

filex storage add runs the same gates as the admin API before it writes: unknown --driver is refused (with the registered names), --config must parse as a JSON object, and the root‑path guard applies. Required fields the driver declares but the config omits are reported as a warning, not an error — an S3 storage on an instance role legitimately ships no keys.

Connect a storage at install time (env / Compose / Helm)

You don't have to open the admin UI at all. A fresh install can come up with a storage already mounted, seeded from environment on first boot only, when no storage exists yet. The seed becomes a normal storage row you can edit afterwards; changing the env later never re‑seeds. Leaving the driver empty seeds nothing.

The variables (see CONFIGURATION.md):

VariableForExample
FILEX_DEFAULT_STORAGE_DRIVERalllocal · s3 · sftp · webdav · ftp · smb
FILEX_DEFAULT_STORAGE_NAMEallFiles (top‑level folder label)
FILEX_DEFAULT_STORAGE_PATHlocal/srv/files
FILEX_DEFAULT_STORAGE_S3_* (BUCKET/PREFIX/ENDPOINT/REGION/ACCESS_KEY/SECRET_KEY/PATH_STYLE)s3see below
FILEX_DEFAULT_STORAGE_CONFIGany driverone line of the driver's config JSON

Use the dedicated vars for local and S3. To connect any other existing external storage (sftp / webdav / ftp), set the driver name and put its config JSON in FILEX_DEFAULT_STORAGE_CONFIG.

Plain binary / systemd / docker run — set env directly:

bash
# an existing S3 bucket (AWS / Hetzner / R2 / Backblaze)
FILEX_DEFAULT_STORAGE_DRIVER=s3
FILEX_DEFAULT_STORAGE_S3_BUCKET=my-bucket
FILEX_DEFAULT_STORAGE_S3_PREFIX=filex
FILEX_DEFAULT_STORAGE_S3_REGION=eu-central-1
FILEX_DEFAULT_STORAGE_S3_ACCESS_KEY=AKIA...
FILEX_DEFAULT_STORAGE_S3_SECRET_KEY=...

# an existing SFTP / NAS server (any driver → one JSON line)
FILEX_DEFAULT_STORAGE_DRIVER=sftp
FILEX_DEFAULT_STORAGE_CONFIG={"host":"nas.example.com","port":22,"user":"filex","password":"s3cret","root":"/srv/files"}

Docker Compose — put the same vars in .env. The shipped deploy/compose/.env.example has ready copy‑paste blocks for MinIO, an external S3 bucket, SFTP and WebDAV.

Helm — set them under storage: in your values (deploy/helm/filex/values.yaml):

yaml
# an existing S3 bucket
storage:
  type: s3
  s3:
    bucket: my-bucket
    prefix: filex
    region: eu-central-1
    endpoint: "https://s3.eu-central-1.amazonaws.com"
    accessKey: "AKIA..."
    secretKey: "..."
yaml
# an existing SFTP / NAS — any driver via `config`
storage:
  type: sftp
  name: NAS
  config:
    host: nas.example.com
    port: 22
    user: filex
    password: "s3cret"
    root: /srv/files

The storage config

FieldTypeDefaultMeaning
namestringDisplay name + top‑level folder label. Required.
driverstringlocal · s3 · sftp · webdav · ftp · smb, or the name of an installed plugin. Required.
configobject{}Per‑adapter settings (see Adapters).
mount_pathstring/Logical mount point inside filex.
sync_modestringpollpoll · fsnotify (the local driver, or a plugin that streams its own changes) · ondemand. Anything else is rejected on write — see Modes.
sync_interval_sint (seconds)900Poll cadence. Values < 5 s are clamped to 15 min.
enabledbooltrueDisabled storages are hidden and not synced.
read_onlyboolfalseBlock all writes to this mount.
rbac_enabledboolfalseWhen true, per‑user RBAC grants gate access; when false the storage is visible to all authenticated users.

Driver descriptors (GET /api/admin/storage-drivers)

Every driver declares its own config contract — the keys it reads, their type, which one is the storage root, which hold credentials, defaults, placeholders and an i18n key per label. The admin UI's storage form, the storage editor and the replication‑target dialog all render from this endpoint, and the root‑path guard reads the same declaration, so a driver's fields cannot drift away from what the backend accepts.

bash
curl -s https://files.example.com/api/admin/storage-drivers \
  -H "Authorization: Bearer $TOKEN" | jq '.[] | {driver, fields: [.fields[].key]}'
json
{
  "driver": "s3",
  "label": "S3 / Hetzner / MinIO",
  "i18n_key": "storages.driver.s3",
  "capabilities": { "read": true, "write": true, "presign": true, "…": true },
  "fields": [
    { "key": "bucket", "type": "string", "required": true, "label": "Bucket",
      "i18n_key": "storages.fields.bucket", "placeholder": "my-bucket" },
    { "key": "prefix", "type": "string", "required": true, "root": true,
      "label": "Prefix", "i18n_key": "storages.fields.prefix" },
    { "key": "secret_key", "type": "password", "secret": true, "…": "…" }
  ]
}

root: true marks the field the root‑path guard checks. aliases lists older spellings of a key that the driver still reads, so configs written before a rename keep working. Adding a driver on the backend puts it in every picker without a frontend release.

capabilities on /api/capabilities still carries the plain storage_drivers: ["ftp","local","s3","sftp","smb","webdav"] name list for older callers.


Adapters

Each adapter's config object is passed verbatim to the driver. Only the keys below are read; unknown keys are ignored. The same key lists are served machine‑readably by GET /api/admin/storage-drivers.

A plugin driver appears in that same endpoint with the fields the plugin described, which is why the admin form renders it without a frontend release. See PLUGINS.md.

local

⚠⚠ Refused on a public demo. FILEX_DEMO_MODE publishes an admin login, and this driver means "a path on this host" — measured on filex's own demo, storages rooted at /data, /etc and /proc/1 were all accepted before the guard existed. Since 0.21.6 the remote drivers are refused on a demo as well: adding one asks the server to connect to an address the visitor chose. A demo ships with the storage it demonstrates.

Serves a directory on the host running filex.

keyrequireddefaultnotes
pathyes*Absolute path to serve. Created (0755) if missing.
rootyes*Legacy alias for path.

*One of path / root. Example: {"path": "/data/files"}. Capabilities: read, write, move, copy, delete, mkdir, live change events (fsnotify). Path traversal (..) is rejected.

NAS (NFS, SMB, and friends)

There are two ways, and since v0.20.0 the first one is usually better.

1 — the smb driver (SMB / CIFS). filex talks to the share itself: give it the host, the share NAME alone (media, not \\nas\media), an account and optionally a sub‑folder. No /etc/fstab, nothing to mount on the host, and the whole configuration stays inside filex.

jsonc
{ "name": "NAS", "driver": "smb", "mount_path": "/",
  "config": { "host": "nas.local", "share": "media",
              "user": "filex", "password": "…", "root": "projects" } }

⚠ There is no nfs driver — NFSv3 needs a privileged source port and, for anything beyond trust-me-it's-uid-1000, Kerberos. Mount NFS with the OS and use option 2. (The other direction does exist: filex can be served as NFSv3 — see PROTOCOLS.md.)

2 — mount it with the operating system and serve the mount point with the local adapter. Still supported, and the only option for NFS. It has three traps, and all three are below.

bash
# NFS
sudo mount -t nfs nas.local:/volume1/files /mnt/nas

# SMB / CIFS — only if you prefer the OS mount to the smb driver above
sudo mount -t cifs //nas.local/files /mnt/nas \
  -o credentials=/etc/nas.cred,uid=1000,gid=1000
jsonc
{ "name": "NAS", "driver": "local", "mount_path": "/",
  "config": { "path": "/mnt/nas/filex" } }

You may not need the mount at all. Most NAS boxes speak protocols filex talks natively — SFTP, FTP/FTPS, WebDAV, or an S3 endpoint (e.g. MinIO running on the box) for the S3 adapter. A native adapter keeps the whole configuration inside filex instead of half of it in /etc/fstab, so prefer one where the NAS offers it.

Docker: mount the share on the host and bind‑mount it into the container (-v /mnt/nas:/data/nas), then point the storage at /data/nas/…. The official image sets no USER, so it runs as root unless you override it — meaning the permission that usually bites is on the NAS side (NFS root_squash, the SMB share's ACL), not inside the container. If you do run the container as a non‑root user, the uid/gid mount options have to match it: CIFS assigns ownership at mount time, not from the file itself.

Trap 1 — use sync_mode: poll, never fsnotify. fsnotify is an OS‑local watch (inotify / kqueue / ReadDirectoryChangesW). It sees what this machine writes to the mount and never sees what another machine writes to the NAS — so a file dropped on the share from a laptop would stay invisible until something else triggered a sync. filex only leaves the OS watch when the driver isn't local — and a mounted share is the local driver: nothing falls back, nothing warns. Choose poll explicitly and set an interval that matches how fresh you need the listing (sync_interval_s; 900 = 15 min is the default when you don't set one, and 60 s is reasonable on a busy share).

A poll over a mounted share detects a file changed by another machine, not only a new one, because a mount has no etag and drift falls back to size + modification time — see Drift detection. ⚠ That rests on the share reporting an honest mtime. NFS attribute caching can serve a stale one for a few seconds (actimeo), and a share backed by FAT keeps timestamps in two‑second steps; neither loses a change, both can delay it to the next pass.

Trap 2 — mount before filex starts. filex creates a storage's root directory if it is missing, so a storage pointed at an unmounted path will cheerfully serve an empty directory, and the next sync run reads "empty backend" as "everything was deleted". The tombstone guard blocks the first such run — it skips the delete pass when a run sees less than ~70 % of what the previous run saw — but it only ever compares against the previous run. Once that empty run is on record with a seen count of 0, the guard has nothing to compare against and the next empty run soft‑deletes the tree from the cache. It buys you one cycle, not safety. Nothing is deleted on the NAS itself, and a sync against a properly mounted share restores the entries, but in between your users see an empty folder. Put the share in /etc/fstab with _netdev (or use a systemd .mount unit and order filex After= it).

Trap 3 — a share is fast to browse and slow to transfer. Listings, search and thumbnails come from filex's own index and caches, so the explorer stays quick over a slow mount; uploads and downloads move real bytes and run at the speed of the network path. See Slow storage.

S3 / S3-compatible

Works with AWS S3, MinIO, Cloudflare R2, Backblaze B2 (S3), Hetzner Object Storage / Ceph RGW, and other S3‑compatible stores.

keyrequireddefaultnotes
bucketyesBucket name.
prefixrecommended""Key prefix = the storage root. Must be non‑empty (root guard).
regionnoautoe.g. us-east-1. auto suits R2/MinIO.
endpointnoCustom endpoint for non‑AWS (e.g. https://minio.example.com). Omit for AWS.
path_stylenoautoPath‑style addressing. Auto‑enabled when endpoint is set (MinIO/Hetzner/B2/R2 need it); AWS stays virtual‑host.
access_keynoStatic key. If omitted, the AWS default credential chain is used (env/IRSA/instance role).
secret_keynoStatic secret (with access_key).
disable_presignnofalseForce filex to stream downloads itself instead of issuing presigned URLs.

Examples

jsonc
// AWS
{ "bucket": "my-bucket", "prefix": "filex", "region": "eu-central-1",
  "access_key": "AKIA…", "secret_key": "…" }

// MinIO / self-hosted
{ "bucket": "my-bucket", "prefix": "filex", "region": "auto",
  "endpoint": "https://minio.example.com", "path_style": true,
  "access_key": "…", "secret_key": "…" }

// Cloudflare R2
{ "bucket": "my-bucket", "prefix": "filex", "region": "auto",
  "endpoint": "https://<account>.r2.cloudflarestorage.com",
  "access_key": "…", "secret_key": "…" }

// Backblaze B2 (S3 endpoint). The region is part of the endpoint host;
// `b2_authorize_account` reports yours as `s3ApiUrl`.
{ "bucket": "my-bucket", "prefix": "filex", "region": "eu-central-003",
  "endpoint": "https://s3.eu-central-003.backblazeb2.com",
  "access_key": "003…", "secret_key": "K003…" }

Gotchas & failure modes

  • Backblaze B2 refuses the MASTER application key on its S3 endpoint, with InvalidAccessKeyId: Malformed Access Key Id on the first write — which reads like a typo in the key rather than the wrong kind of key. Create an ordinary application key (Backblaze console → Application Keys → Add a New Application Key) and use that. Scope it to the one bucket while you are there. Measured 2026-09-12: with an application key, filex's whole storage surface passes on B2 — write, prefix listing, ranged read, a 12 MiB multipart upload read back byte-for-byte, rename, a presigned URL fetched by a browser, and delete (internal/storage/drivers/s3, TestLiveProviderConformance).
  • Hetzner Object Storage / Ceph RGW reject some AWS‑SDK presigned URLs with SignatureDoesNotMatch. If downloads fail there, set "disable_presign": true — filex then streams the bytes itself.
  • Empty folders are represented by a hidden .empty marker object (created on mkdir, hidden from listings). Folder move/delete/copy recurse the prefix, so deleting or renaming a folder works even though S3 has no real directories.
  • Filenames with spaces or non‑ASCII characters are fully supported (the copy source is URL‑encoded).
  • bucket missing → the storage won't initialize (Test connection shows the error). Wrong keys/endpoint → Test connection fails with the SDK error.
  • Uploads are retried, up to 8 MiB. A request can only be retried if its body can be rewound, and every upload surface hands the driver a plain stream (the handler sniffs the first bytes to detect the type). So an upload that declares a size of at most 8 MiB is held in memory while it is sent and survives a transient 503; a larger one streams straight through and fails the request if the store wobbles mid-upload. The trade-off is deliberate: buffering every body would turn a rare failed upload into an out-of-memory kill on a large one. Browser uploads of big files go out as multipart parts, which the store can be asked for again independently.

SFTP

keyrequireddefaultnotes
hostyesServer hostname/IP.
useryesSSH username.
passwordone‑ofPassword auth.
private_keyone‑ofPEM private key (string). Use instead of / with password.
key_pathone‑ofPath to a key file on the server, read at Init when private_key is empty.
portno22Integer.
rootyes/Base directory. Must be a sub‑folder — the root guard rejects /. Aliases: base_path, remote_path.
known_hostsno~/.filex/known_hostsStrict OpenSSH known_hosts path.
host_keynoPin a single host key.
insecure_skip_host_keynofalseDisable host‑key checking (not recommended).

user also accepts the legacy spelling username. Provide either password, private_key or key_path. Host‑key handling: if you don't pin a key or supply a known_hosts file, filex uses trust‑on‑first‑use — it records the server key on first connect and refuses if it later changes (a MITM signal). Example: {"host":"sftp.example.com","user":"filex","private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\n…","root":"/srv/files"}.

WebDAV

Tested against Nextcloud, ownCloud, Apache mod_dav, nginx‑dav, SabreDAV.

keyrequireddefaultnotes
urlyesWebDAV base URL.
useryesBasic‑auth user. Alias: username.
passwordnoBasic‑auth password.
rootyes""Sub‑folder under the base URL — the mount point. Aliases: base_path, remote_path.

root is joined onto the base URL's path; a storage saved before the driver read it (empty root) still mounts exactly at the URL, unchanged. Only Basic auth is supported today (Bearer is planned). Example: {"url":"https://cloud.example.com/remote.php/dav/files/alice/","user":"alice","password":"…","root":"filex"}. MKCOL/MOVE/COPY/DELETE/PROPFIND back the file operations.

FTP / FTPS

keyrequireddefaultnotes
hostyesServer hostname/IP.
useryesUsername. Alias: username.
passwordyesPassword (required, unlike SFTP).
portno21Integer.
rootyes/Base directory. Must be a sub‑folder — the root guard rejects /. Aliases: base_path, remote_path.
tlsnofalseExplicit FTPS (AUTH TLS).
passivenotruePASV mode; set false to disable.

FTP uses a single serialized control connection, so it's the slowest adapter and copies stream through a temporary file. Prefer SFTP where possible. {"host":"ftp.example.com","user":"filex","password":"…","root":"/files","tls":true}.


Sync

filex serves listings from its DB cache, so it periodically reconciles that cache with the real backend to catch changes made outside filex (e.g. a file uploaded straight to the S3 console).

Modes (sync_mode):

  • poll (default) — a full recursive walk every sync_interval_s seconds. Intervals below 5 s are clamped to 15 minutes.
  • fsnotify — event‑driven instead of timed. It resolves in this order: the OS watch (inotify / kqueue / ReadDirectoryChangesW) when the driver is local; otherwise the driver's own change stream, when it has one — today that means a storage plugin declaring watch, since no built‑in remote driver implements it; otherwise it falls back to poll. Either way a 2‑second debounce coalesces bursts like tar -xf, and every batch triggers the same full run a poll would, so an event stream affects latency, never correctness. ⚠ A driver stream that ends (a plugin restarts, a connection drops) drops the storage back to polling rather than leaving it frozen with a stale index.
  • ondemand — only syncs when explicitly triggered (POST /api/admin/storages/{id}/sync).

Those three are the whole list, and the server enforces it: a sync_mode it does not implement is refused when the storage is created or changed, with a message naming the modes that exist. It used to be stored as typed — a fsnotifiy typo saved happily and the storage quietly ran the poll loop, so the page showed a mode nothing was doing.

push is not one of them. It was declared as an enum value for "the backend pushes changes at us" and nothing was ever built behind it, so a storage set to push polled. It is now rejected like any other unsupported value; if you want an external writer to drive the sync, use ondemand and call POST /api/admin/storages/{id}/sync from that writer. Rows that already say push (only reachable by hand or by an API call made before this release) are left exactly as they are: they keep polling as they always did, they stay editable — a rename or a disable still saves — and the server now logs sync: unsupported sync_mode, falling back to poll once per storage at startup so the discrepancy is visible instead of silent. Changing such a row's mode to another unsupported value is what gets refused.

What a sync does: new objects are indexed, changed objects are updated (see Drift detection), and objects gone from the backend are soft‑deleted from the cache. A newly catalogued file and a file whose content drifted are also queued for an antivirus scan, one priority step below everything a person asked for, so a first import of twenty thousand files does not make an upload's scan wait behind it (PROTECTION.md → Files the sync discovers).

What a sync does not do: it never un‑deletes. Deleting in filex is a rename — the bytes move to .filex-trash/ and the row is soft‑deleted and retagged to that key — and the walk used to see the object, find no live row, find the soft‑deleted one and clear deleted_at. On every pass, on every driver, with no condition attached, which meant a deletion undid itself and an infected file left quarantine on a timer nobody set (quarantine is the same operation). Two rules replace it:

  • the walk skips .filex-trash/ — the rows for everything in there already exist, retagged to those very keys, and the trash service owns them;
  • a trashed row is never revived. An object at a path where a trashed row still sits is catalogued as a new node; the old row stays in the trash, restorable and on the retention clock. Bytes that reappear at a path are not the file that was deleted there.

⚠ Anything found live inside .filex-trash/ is repaired, which is what heals an install that ran the old code: a revived deletion is soft‑deleted again (keeping its storage_key, so restore still knows where to put it back) and a row minted for the trash's own bytes is dropped. Bytes are never touched either way. A tombstone guard protects against transient backend glitches: if a run sees fewer than ~70 % of the objects the previous run saw, the delete pass is skipped (so a flaky S3 endpoint doesn't wipe your tree from the cache).

On the first pass after upgrading to v0.34.0 this guard may trip once, and that is expected. seen no longer counts objects inside .filex-trash/, so a storage whose trash held more than ~30 % of its objects looks like it shrank: one warning, one skipped delete pass, and the next run compares like with like.

⚠ The comparison is against the previous run only: a backend that stays empty records a run with a seen count of 0, and the run after that has nothing to compare against and deletes. The guard buys a cycle to notice the outage in — see NAS trap 2.

Cadence is per storage. The poll loop uses the storage row's sync_interval_s (900 when you don't set one; anything under 5 s is treated as 15 minutes). Every enabled storage gets its own goroutine and walks its backend sequentially — there is no shared worker pool, so set the interval on the storage rather than looking for a global knob.

You can watch runs at GET /api/admin/storages/{id}/sync-runs and detect drift with GET /api/admin/storages/{id}/drift.

Drift detection: what a replaced file looks like

Catching a file that was changed outside filex is the whole point of the sync, so it matters exactly how "changed" is decided.

With an etag — the backend's own content fingerprint — that is the answer, and it is exact. Only S3 and WebDAV report one.

Without one — local, SFTP, SMB, FTP, and any WebDAV server that omits the header — the comparison is the file's size and modification time, the two fields every one of those drivers does report. Both are already in the listing the walk just made, so a full walk costs what it always did: measured over 20 000 files on a local disk, three consecutive passes took 3.0–4.0 s before the change and 3.0–4.0 s after, and reported zero drift on every one of them.

Change made outside filexNoticed?
An ordinary edit — content and mtime both moveyes
A rewrite that keeps the same size (a config line swapped for one the same length)yes, the mtime moved
A file that grew or shrank, even with its mtime preserved (cp -p, rsync --times)yes, the size moved
A restore from backup, whose mtime is older than the row'syes — the test is inequality, not "newer than"
A replacement that preserves both the size and the mtimeno
A rewrite landing in the same clock second as the recorded mtime, with the size unchangedno

The last two need the file's content to detect. Hashing every file on every pass would turn a three-second walk of 20 000 files into something nobody can run — the same trade-off folder sync makes, and the one rsync makes by default. If a storage holds files that are rewritten in place without their size or timestamp changing, an on-demand POST /api/admin/storages/{id}/sync does not help either; nothing short of re-reading the bytes will.

Times are compared to the second. Finer would not detect more: Postgres stores timestamps to the microsecond, FTP's MDTM has no sub-second field at all, and FAT keeps two-second steps — so a finer comparison would report drift on every pass for files nothing touched, which on an install with antivirus enabled means re-scanning the whole storage every sync interval, forever.

Directories are not drift-checked. A folder's row carries its cached recursive size so the explorer can show folder sizes; a listing reports the directory entry's own few kilobytes. They never match, and comparing them would mark every folder on the storage as drifted on every pass.


Slow storage

A NAS over a VPN, an SFTP box on the other side of the country, a bucket in another region — filex is built so that the slow part stays the slow part.

Already fast, nothing to configure:

  • Listings are served from the DB cache, not the backend. The driver is only consulted for a storage that has never synced (so a brand‑new mount isn't empty while the first walk runs).
  • Search runs against filex's own index/database and never touches the backend at query time (see SEARCH.md).
  • Thumbnails are generated once and cached on local disk, so the second visit to a photo folder costs nothing.

Worth tuning:

KnobWhereWhy
sync_interval_sstorage rowEvery poll is a full recursive walk of the backend. On a big, rarely changing share, raise it.
sync_mode: ondemandstorage rowNever walks on its own — you trigger it with POST /api/admin/storages/{id}/sync (e.g. from the job that writes to the share).
filex thumb backfillCLIPays the first‑browse cost up front instead of making a user wait. Takes --storage <id|name>, --limit N, --concurrency N, --retry-failed.
FILEX_THUMB_BACKFILL_ON_BOOT=onceenvSame thing, once, in the background at startup.
disable_presign: trueS3 configThe opposite of a speed‑up: it forces download bytes through filex instead of a redirect straight to the bucket. Use it only when presigned URLs don't work for your users (Hetzner/Ceph SignatureDoesNotMatch, or a bucket that isn't reachable from the browser).

Downloads support ranges. GET …?action=download|preview answers Accept-Ranges: bytes and serves 206 / Content-Range for a Range request, so video and audio seek, a dropped download resumes from where it stopped instead of restarting, and only the missing bytes are re-read from the backend. All six built-in drivers (local, s3, sftp, ftp, smb, webdav) can start a transfer at an offset; a driver that could not would answer Accept-Ranges: none and serve the whole object, never a wrong window. Public share links (/s/…) deliberately stay whole-object: one request there is one download against the link's cap.

Prepared copies for big downloads

Ranges make a download resumable and seekable, but they do not make a slow backend fast. So when a big file lives on a slow storage, filex fetches it to local disk once and says so while it happens:

  1. the first ?action=download is answered 202 — a progress page in a browser, {"state":"preparing","percent":N} for an API client;
  2. the client polls ?action=download&…&cache=status (the page does it for you) until {"ready":true};
  3. from then on the file is served from local disk — for every surface, with full Range support, at local-disk speed, without touching the backend again.

The copy is keyed on the file's identity (its ETag, or size+mtime for backends that have none), so a changed file invalidates itself: the next request prepares the new content rather than serving the old.

When it happens. Both conditions, together:

ConditionHow
The file is bigsize ≥ FILEX_CACHE_MIN_SIZE (default 64 MiB)
The storage is slow"slow": true in the storage's config, or measured below FILEX_CACHE_SLOW_BPS (default 10 MiB/s)
jsonc
{ "name": "nas", "driver": "local", "config": { "path": "/mnt/nas", "slow": true } }

When it deliberately does not happen — the rule being "never make it worse":

  • Small files are never prepared, whatever the flag says. One round trip beats a preparing screen.
  • A storage that measures fast is not prepared even if you flagged it: a measurement at twice the threshold overrules the flag, because on a fast backend a prefetch replaces an instant stream with a wait. (Move a share onto a faster link and filex notices; you do not have to remember the flag.)
  • Previews never wait. Scrubbing a video asks for a window, and it gets one. A preview still uses a copy that already exists.
  • Range requests are never answered 202 — a resume or a seek is a client already committed to a body.
  • Public share links are never answered 202 either: they spend one of the link's capped downloads before bytes leave, and "not yet" is not something to charge a visitor for. They do read from a copy that exists.
  • Files still being uploaded (transfer_state: staged) are not prepared — their bytes are already on filex's local disk.

Disk. The cache directory (<data_dir>/cache) has a global ceiling, FILEX_CACHE_MAX_BYTES, default 20 GiB, enforced with LRU eviction and counting copies that are still being fetched. It is never unlimited. An entry a request is currently reading is never evicted; when nothing can be freed, the new file is simply not prepared and streams from the backend as before.

What is not tuned away: moving bytes still takes as long as the link takes. An upload lands in filex's staging area first, so it is resumable and the client stops waiting on the backend (UPLOADS.md) — but the transfer to the backend still runs at the backend's speed, and the first download of a big file pays the full fetch before it is served. What the prepared copy buys is that nobody pays it twice, and that the person waiting is told why.


Moving files between storages

Copy, cut and drag work across storages, not only inside one. What each gesture means is the rule every desktop file manager taught its users:

GestureSame storageDifferent storages
Ctrl+C → pastecopycopy
Ctrl+X → pastemovemove — the bytes are streamed over, then the original is deleted
Drag onto a foldermovecopy — the original stays, exactly like dragging between two drives

Across two storages there is no server-side rename to hand a driver (an S3 bucket cannot rename a file into an SFTP host), so filex streams the bytes itself, one file at a time, through the queue you can watch in the ops tray:

  • a whole tree travels, empty folders included;
  • each file keeps its own modification time wherever the target can hold one, so a moved tree does not read as "everything changed just now" to the next sync run;
  • every file is stat-checked on the far side before anything is deleted — a backend that accepts a write and stores fewer bytes fails the step instead of turning a move into data loss;
  • a name already taken on the target becomes name-copy, name-copy-2, …; nothing is overwritten;
  • filex's own .filex-trash and .thumbs are skipped — they belong to the storage they are in.

⚠ A cross-storage move deletes the source outright; it does not go through the trash. Moving between storages is usually done to free the first one, and a trashed copy would keep both the bytes and the quota until the trash is emptied.

Refusals happen at submit time, with a reason: an unknown target storage is a 400, a read-only target a 403 naming the storage, and a target folder you have no editor rights on a 403 — the permission is checked in the destination's storage, which is the one being written to.

Read-only mounts

Set read_only: true to expose a storage for browsing/download but block every write (upload, rename, move, delete, share‑drop). Writes return 403 storage is read-only. Useful for archives or a replica you don't want edited.


Path validation & errors

Root‑path guard. The API/UI reject a storage whose prefix/root is empty or / with:

ROOT_PATH_FORBIDDEN: storage prefix/path cannot be empty or root '/';
use a sub-folder like 'fileman' or 'data/files'

Always mount a sub‑folder (S3 prefix, or root/path for the others).

Driver errors → HTTP: not found → 404, read-only → 403, unsupported → 501, already exists → 409, anything else → 500. The Test connection endpoint surfaces the raw driver error so you can debug credentials/endpoints before saving — prefixed with a timeout notice if the probe hit its 10-second bound rather than getting an answer.


See also

Released under the MIT License.