Skip to content

Protocols - reaching filex without a browser ​

filex speaks a protocol in two directions. Whatever it can connect to, it can also be reached as:

Protocolfilex connects to it (storage driver)filex is reachable as it (server)
Local disk✅- (it is the disk)
S3✅✅ on by default (FILEX_S3=0 to switch off)
SFTP✅✅ FILEX_SFTP=1 - off by default
FTP / FTPS✅✅ FILEX_FTPS=1 - off by default, explicit TLS only
WebDAV✅✅ /dav, on by default
NFSv3- (mount it on the host and use the local driver)✅ FILEX_NFS=1 - off by default
SMB / CIFS✅❌ - see Why no SMB server
HTTPS (filex mount)-✅ always, no server-side switch

A backend that is in neither column can still be mounted: a storage plugin is a separate program that teaches filex a backend it does not ship, and a storage on one is served over every protocol in the right-hand column exactly like a built-in - WebDAV, SFTP, FTPS, NFS and the S3 endpoint each have a test against a live plugin. ⚠ When such a plugin is down, those surfaces answer an error, never an empty listing: a mirroring client that saw an empty success would delete the user's local copy.

Everything below lands in the same tree, with the same RBAC grants, the same trash, the same quota, the same search index and the same audit trail as the web UI. A file uploaded over FTPS is thumbnailed, indexed and counted exactly like one dropped in the browser, because every protocol writes through the same funnel - and since v0.34.0 it also announces itself on the realtime socket, so a browser with that folder open sees it appear rather than finding out on its next navigation (REALTIME.md). ⚠ A write over any of these protocols that replaces an existing file emits file.updated, not file.uploaded - worth knowing if you have a webhook watching protocol traffic (NOTIFICATIONS.md).

A rename (or MOVE) and a delete over any of them also keep out of the storage scan's way the way the web UI's do (since 0.55, issue #201): the bytes and the rows that follow them go under the storage's row gate, so a scan running beside the client never sees a folder at its new name with its rows at the old one, and never drops a trashed file's row - and the trash entry it could be restored from - before the rename or delete has written it (ARCHITECTURE.md → The row gate). The verb waits for the gate on the client's request or session, so a client that gives up while it waits changes nothing, and once it has the gate it asks again what it checked before (a destination found free, the item it was asked about): a MOVE or rename that waited refuses a file that landed on its name meanwhile instead of replacing it. A folder moved or deleted into the trash on an object store, which goes one object at a time, fences only its own path and its destination's instead of the whole storage: the scan leaves those alone and goes on everywhere else until the rows have followed.

⚠⚠ One thing is not shared: version history. WebDAV and the S3 gateway take a pre-write snapshot; SFTP, FTPS and NFS do not, so an overwrite over those three destroys the previous bytes silently. See TRASH-VERSIONING.md.

⚠ Nor the catalogue's "unavailable" mark (0.50, issue #104). An entry the storage could not answer for is listed in the explorer with a warning and refused on every filex surface - the explorer, the REST and AI/MCP APIs - with 409 ENTRY_UNAVAILABLE (PLUGINS.md). WebDAV, SFTP, FTPS, NFS and the S3 gateway serve the storage driver directly and never read that mark: a client of theirs meets whatever the storage itself answers for the path.

Every one of them addresses a storage the same way - the first path segment, or the bucket over S3 - and every one accepts either the storage's name or its immutable uid. Give a machine the uid and the mount survives a rename; the rule is one function (internal/storageref) rather than five, so there is no protocol where only one of the two works. See STORAGE.md.

The connection instructions are in the app, not here: How to connect - the entry in the file explorer's navigation panel, on every surface that draws it (the web app, the desktop app, any embed) - builds every command from this deployment: its host, its port, your login, the key or export you just minted. Admins reach the same panel from Connections in the admin menu (Files & storage). This document is the map; that page is the thing you copy from.


The one thing to understand first: credentials ​

Each protocol has a credential you can revoke on its own, without touching your password and without disturbing the others.

ProtocolWhat you presentWhere it is minted
S3access key id + secret (SigV4)How to connect → S3
SFTPyour login + an API token as the password, or a registered SSH public keyHow to connect → SFTP
FTPSyour login + an API token as the passwordHow to connect → FTPS
NFSv3the export path itself - it carries 32 bytes of entropyHow to connect → NFS
WebDAVyour login + an API token as the passwordany token
filex mountFILEX_URL + an API tokenany token

Wrong passwords over SFTP, FTPS and WebDAV count against the same sign-in attempt limit as the web form: past it the account (or the address) is locked, WebDAV answers 429 with Retry-After, and FTPS / SFTP simply refuse. An API token is not a password attempt and is never counted. ⚠ But while that name or that address is locked, a token given as the password is refused too: somebody else's wrong guesses can pause a token mount for as long as the lock lasts (up to 15 minutes with the default settings). A registered SSH key is not affected. The counters are the web form's own - one set, decided in the server's memory and written through to the database. The address counted is the socket's peer on FTPS and SFTP; on WebDAV, which arrives through the web server, it is read through the trusted proxies (by default auto: this machine and, in a container, the other containers on its network).

Every credential in that table is a person's - mint them while signed in as yourself, or from a user API token. An app token (a host app's proxy, a bot) is refused by all four self-service credential surfaces (API tokens, S3 access keys, SSH keys, NFS exports); its explorer does not show the API keys entry, and "How to connect" keeps the guides but replaces the mint forms with a line saying this session cannot create credentials. See MCP.md → Token kinds.

Your login is your username if you have set one, otherwise your email. Both work everywhere; an @ in an SSH or FTP login has to be quoted in most clients' config files, which is what usernames are for.

Your account password is accepted too, wherever the table says "API token" - a token is simply the credential you can revoke without changing your password. On an install with LDAP/AD that includes the directory password, and with a Windows or Linux (PAM) provider (OS-LOGIN.md) the operating-system password: every such provider that is running is asked in turn when the local users table cannot judge the password, and a successful check is cached for five minutes because these protocols present the credential on every request. Operators switch that off per provider with its protocol_login setting (Admin → Identity providers; auth.ldap.protocol_login: false for LDAP configured from the config file). S3 and NFS never take a password - they use access keys and exports.

⚠ An account with 2FA enabled cannot use its password on any of these. None of these protocols has a channel for a second factor, so accepting the password would make each of them a documented 2FA bypass. Mint a token (or a key, or an access key) - that is the app-specific-password pattern, and each one is individually revocable.

⚠ Revoking reaches sessions that are already open. Deleting a token, disabling a key or revoking an export cuts the SFTP/FTPS connection it opened and stops the NFS mount within about half a minute - not only the next login.

Multi-tenant installs: the realm ​

Skip this on a single-tenant install: there a user name is only a user name, and acme/alex is a name nobody has.

On a multi-tenant install two tenants may each have an alex, so a password sign-in has to say which tenant it is for before an account is looked up. A protocol says it the way the web page does - by the address the client reached - when it carries one; when it carries none, the user name says it as realm/name:

ProtocolThe tenant comes fromExample
WebDAVthe Host the client connected to (the web page's own resolver)map https://files.acme.example/dav/ as alex
WebDAV on the platform's address, or for a tenant with no address of its ownthe user nameacme/[email protected]
FTPSthe name the client sent in the TLS handshake (SNI)connect to files.acme.example as alex
FTPS, a client that sends no SNI (or connects by IP)the user nameacme/alex
SFTPthe user name - alwaysacme/alex, acme/[email protected]
  • Where no address names a tenant (SFTP always, FTPS without SNI, the platform's own address), a bare name is the platform's own tenant. /alex says the same outright.
  • An address and a realm that disagree are refused: beta/alex on acme's address is not beta's alex, it is nobody. So is a realm nobody has. Both are answered - and counted - exactly as a wrong password.
  • The lookup never leaves the tenant: in realm acme, a bare alex is looked up as the address a directory would have given it ([email protected]), then as a username; a name typed as an e-mail address is looked up as typed and nothing else. Only acme's accounts can match.
  • Why SFTP needs the realm in the name. SSH has no equivalent of HTTP's Host or TLS's SNI: the client never tells the server which name it dialled, so sftp [email protected] and sftp [email protected] arrive identically. Only the user name can carry the tenant. (FTPS can read SNI because its control channel is TLS; plain FTP is never served - see FTPS.)
  • A token or a registered SSH key needs no realm. The credential belongs to one account, and the tenant is that account's: alex with acme's token or key signs in as acme's alex. A realm that is written must be the credential's own - acme's key as beta/alex is refused.
  • FTPS on a tenant's own address needs a certificate valid for that name if the client verifies it (one certificate can carry every tenant's name).
  • The CLI - filex client login, whose session filex sync and filex mount use too - takes the realm as --realm acme on the platform's address and follows the web page's handoff to a tenant's own address (CLI.md); with an API token (FILEX_TOKEN) it needs none.
  • The sign-in attempt limit counts <realm>/<name>: acme/alex, alex on acme's address and the web form's alex in realm acme are one counter; beta's alex is another.

How to connect prints your login already written this way: a tenant account's SFTP / FTPS login is realm/username, and its WebDAV user realm/e-mail.

A token carries its verbs onto every protocol: signed in with a token as the password (WebDAV, SFTP, FTPS), or through an S3 access key or an NFS export minted from a token, read lists and downloads, write creates, changes, renames and moves, delete removes - so a read token is a read-only mount. A key or export minted from a browser session carries every verb. On top of that the account's permissions apply: access.webdav, access.sftp, access.ftp, access.s3 and access.nfs decide who may use each protocol at all, and the file permissions each operation (PERMISSIONS.md → Where each permission is enforced).


S3 ​

FILEX_S3=1. A bucket is a storage: main, photos, archive are what ListBuckets returns, filtered to the ones you can see.

  • SigV4 verified against the SDK's own signer, so the clients that matter agree byte for byte. Tested against aws-cli, rclone, restic, mc and s3fs.
  • ListObjectsV2 and V1, delimiters and CommonPrefixes, real Range requests, composite ETags, x-amz-meta-mtime, multipart uploads on filex's own staging area, the modern x-amz-checksum-* contract (header and trailer), and directory markers so mkdir works over s3fs.
  • aws-chunked bodies (STREAMING-… payloads) are decoded, their chunk signatures verified, and held to the x-amz-decoded-content-length they declare: that number is what the quota and the per-file limit are asked with, so a body (or a part) that is longer or shorter is refused with 400 IncompleteBody and nothing is kept. The catalogue records the size that landed on the storage (since 0.54.0).
  • Path-style and virtual-hosted addressing both work.

⚠ Give the endpoint its own hostname (FILEX_S3_DOMAIN=s3.example.com) if you can. Without one it lives under /s3, and a client pointed at the application root gets the web app's HTML - rclone reports "XML syntax error on line 10", which says nothing about what is wrong. Never point FILEX_S3_DOMAIN at the host the app itself serves.

Under a base path (filex served under a sub-path, FILEX_BASE_PATH=/filex) the path-style endpoint is https://example.com/filex/s3 and works as it does at the root: the client signs /filex/s3/<bucket>/<key>, and filex verifies the signature against exactly that path - the one the client sent - though the prefix is taken off before the request is routed. Tested with the AWS SDK's own signer, header- and query-signed (presigned URLs). The reverse proxy must pass the path unchanged: a proxy that strips or rewrites it changes what was signed and every request answers SignatureDoesNotMatch. A dedicated host (FILEX_S3_DOMAIN) is served at its own root, never under the app's base, and is not affected by it.

⚠ A bucket you cannot reach answers NoSuchBucket, never AccessDenied - the same thing S3 does cross-account, because the alternative is an existence oracle. A write you are not allowed answers AccessDenied, because a client told "no such key" would retry forever against a permission problem.

filex's own bookkeeping trees - .versions/, .thumbs/, .filex-trash/, the desktop app's open-with working area .filex-open/ and the people's drafts.filex-drafts/ - are not exposed here, at any depth, on any verb: not listed, not readable by known key, and not writable. A draft is not shown even to its owner over a protocol: it is not a file yet. Every protocol judges this by the same list (backend/internal/syspath); before it existed each carried its own three-name copy and all of them listed .filex-open/. Every other protocol has always hidden the first three; the S3 gateway is the one that had to catch up, and it matters most here because version history holds a copy of every file this gateway has replaced - the .versions/ tree is real data, not scratch space.

SFTP ​

FILEX_SFTP=1. Its own TCP listener (default :2022 - sftpgo and rclone serve sftp both use it, while 2222 means "SSH in a container"), not a route.

  • Password (an API token) or a registered public key. ssh-copy-id cannot work against filex - it appends to ~/.ssh/authorized_keys over a shell and filex has no shell - so keys are pasted at Connections → SFTP.
  • exit-status is sent on the subsystem channel, so scp reports success correctly. OpenSSH 9+ speaks SFTP for scp, and without this every copy ended in a silent exit 1 with the bytes already transferred.
  • posix-rename, statvfs (your quota is what df shows), permission bits synthesised from your ACL level, resumable reads and writes.
  • Tested with OpenSSH, sshfs, WinSCP, FileZilla, rclone.

⚠ Only the sftp subsystem is served. exec and shell are refused: filex has no shell, and answering an exec request is how a file server grows a command-execution surface.

FTPS ​

FILEX_FTPS=1. For the equipment that only ever learned FTP - scanners, multifunction printers, older cameras, industrial controllers.

  • Explicit TLS is mandatory, on the control channel and the data channel. There is no switch to turn it off: plain FTP sends the password in the clear and the file after it.
  • Passive mode only. Active mode has the server dial the client, which does not survive NAT and turns the endpoint into something that makes outbound connections to an address the client chose.
  • ASCII conversion is disabled. FTP's ASCII mode rewrites line endings, and on a file a client guessed wrong about that is silent corruption of somebody's data.
  • REST/APPE resume, and a transfer that fails mid-flight does not leave a truncated file under the real name.

⚠ The passive port range matters as much as the port. A firewall that blocks it makes every transfer hang with no error on either side - the classic FTP failure, impossible to guess at from the client end. Set FILEX_FTPS_PASV_MIN/_MAX and open both.

Certificates. Without FILEX_FTPS_CERT/_KEY filex generates a self-signed pair and the connection guide says so. With them, the files are re-read whenever they change - every handshake checks their mtime and size - so a real, auto-renewing certificate can be bound: mount your reverse proxy's certificate directory read-only, point the two variables at the .crt/.key, and the renewal Caddy or certbot writes every couple of months is what the next FTPS client sees, with no restart. (Before v0.25 the pair was read once at start-up, which is why a renewed certificate would have been served expired for weeks while /healthz stayed green.) A renewal that lands half-written, or a key that does not match its certificate, keeps the previous pair serving and logs a warning once; a good pair written afterwards is picked up.

NFSv3 ​

FILEX_NFS=1, off by default and meant for a LAN or a VPN.

NFSv3 cannot authenticate a request in a way filex can use: real identity means RPCSEC_GSS, which means Kerberos, and AUTH_SYS - what every NAS actually ships - is the client asserting "I am uid 1000" with nothing to check it against.

So the identity is bound to the export, not to the request. Each export gets a path with 32 bytes of entropy in it, and the path is the credential. The mount is pinned to one account for its whole lifetime; the uid and gid on each request are discarded rather than trusted, and the permissions you see are synthesised from your ACL.

⚠⚠ NFSv3 is unencrypted. Anyone who can read the traffic sees your files, and anyone who learns the path can mount them. LAN or VPN only - for anything off-LAN the answer is filex mount.

⚠ There is no portmapper on port 111, so every client must be told the port explicitly: -o port=2049,mountport=2049,nfsvers=3,nolock. Windows' "Client for NFS" has no way to say that at all, so it only works when filex is on the standard 2049.

⚠ NFSv3 has no "close", so the server opens, writes and closes the handle on every write RPC and filex commits on each close: one cp -p of a 5 MB file is a CREATE plus five 1 MiB writes. That used to reach every open browser as six separate change frames; they are now coalesced into two (REALTIME.md). Nothing is dropped - the last frame of a burst always reflects the final state - so this is a difference you notice only in how quiet the page is.

WebDAV ​

/dav, on by default. The oldest surface, and the one Windows Explorer and macOS Finder map natively.

  • Class-2 locking, which those clients require before they will write. The locks are durable: they are written to <data>/dav/dav-locks.json and read back at boot with their tokens intact, so a restart or a deploy does not silently drop a lock a client still believes it holds.
  • Quota enforced with a proper 507 Insufficient Storage before the upload starts.

⚠ Locks are per-process, not distributed. One filex process per deployment is the assumption; two processes serving the same storage would each grant locks the other does not know about.

filex mount ​

No server-side switch - it is the same binary as the CLI, talking to the REST API over ordinary HTTPS.

bash
export FILEX_URL=https://filex.example.com
export FILEX_TOKEN=<token>
mkdir -p ~/filex && filex mount ~/filex
fusermount -u ~/filex        # ⚠ unmount this way, not by killing the process

It is the only option here that works from anywhere: no LAN, no extra port, no third-party client, and it reaches the server through whatever proxy or tunnel the browser goes through.

It is not a sync. Nothing is copied to the machine except a bounded read cache, so it opens one file out of a hundred thousand without downloading the rest. If you want the files when you are offline, use folder sync (SYNC.md) instead.

On Windows the mountpoint is usually a drive letter, and WinFsp (winfsp.dev, free) has to be installed once:

powershell
$env:FILEX_URL   = "https://filex.example.com"
$env:FILEX_TOKEN = "<token>"
filex mount Z:            # ⚠ Z: must be FREE - it is created, not reused

Stop it with Ctrl-C in that window.

⚠ macOS is not supported. It needs macFUSE, whose Go binding needs a C toolchain filex deliberately does not use and whose licence forbids a commercial program from installing it. The command refuses there rather than appearing to work and doing nothing - use the desktop app with folder sync instead.


Connecting to an SMB / NAS share ​

SMB is the one asymmetric row: filex can use a NAS as a storage, but does not serve SMB.

Add a storage (Admin → Storages) with driver SMB / CIFS, give it the host, the share name alone (media, not \\nas\media), an account and optionally a sub-folder. Everything else - RBAC, trash, versions, quota, search, thumbnails - behaves as it does on any other storage.

⚠ A NAS is usually the slowest storage in an install. Pair it with the download cache and the staged-upload path (STORAGE.md).

Why there is no SMB server ​

Not a licence problem and not a protocol problem - a size problem, stated plainly:

  • There is no MIT/Apache-licensed, mature Go SMB server. The one that genuinely works (macos-fuse-t/go-smb2) is AGPL-3.0, which would relicense filex; the MIT one (gentlemanautomaton/smb) says "not yet suitable for use" in its own README.
  • Writing it means MS-SMB2 from NEGOTIATE upward: NTLMSSP session setup, TREE_CONNECT, CREATE with its create-contexts, READ/WRITE, IOCTL, locks, CHANGE_NOTIFY, oplocks and leases, durable handles, signing and encryption. Windows does not consider a share "working" until most of that is there. It is legal (MS-SMB2 is an open specification) and it is the single largest item on filex's board.
  • And the thing people actually want from it - a drive letter - is already answered by filex mount off-LAN and by NFSv3 on-LAN, at a fraction of the cost.

If it is ever written, note that port 445 is not carried across the public internet by most ISPs; the way out is SMB over QUIC (TLS 1.3 on UDP/443, ALPN token smb), which needs Windows 11 24H2+ / Server 2025 on the client side, or Samba 4.23+ on Linux.


Turning them on ​

bash
# S3 and WebDAV are ON already; these switch them OFF.
FILEX_S3=0
FILEX_DAV=0

FILEX_S3_DOMAIN=s3.example.com  # ⚠ never the app's own host

FILEX_SFTP=1
FILEX_SFTP_ADDR=:2022           # the default

FILEX_FTPS=1
FILEX_FTPS_ADDR=:2121           # the default; 21 needs root
FILEX_FTPS_PASV_MIN=30000
FILEX_FTPS_PASV_MAX=30100       # ⚠ open this range on the firewall too

FILEX_NFS=1                     # ⚠ LAN / VPN only
FILEX_NFS_ADDR=:2049            # the default; Windows can only use 2049

FILEX_SECRET_KEY=<32+ random bytes>   # ⚠ required once S3 keys exist

S3 and /dav are on because neither opens a port of its own and both refuse every unsigned or unauthenticated request; a credential still has to be minted before anything can reach them. The three that open a listener are off until asked for - a port nobody requested is not something to open for them.

⚠⚠ FILEX_SECRET_KEY is a deploy requirement, not an option. S3 secrets cannot be hashed the way tokens are - SigV4 verifies a request by recomputing an HMAC chain from the secret, so the server must be able to recover it. It is sealed with AES-GCM under this key. Without the key configured, minting an access key fails rather than storing plaintext; change or lose the key and every existing access key stops verifying. Back it up with your database.

Full variable reference: CONFIGURATION.md.

Released under the MIT License.