# Deployment
Running filex in production. This doc assumes you've already got an instance up
(see [INSTALLATION.md](INSTALLATION.md)) and covers what sits **around** it: the
reverse proxy, TLS, the one setting that trips everyone up (`FILEX_PUBLIC_URL`),
scaling, backups, health checks, and a hardening pass.
filex is a single Go process that serves **everything from one origin** on port
`5212` - the admin SPA, the JSON API, public share (`/s/…`) and file-drop
(`/d/…`) pages, the `/embed.js` web component, the realtime WebSocket at
`/api/ws`, the MCP stream at `/api/ai/mcp`, and `/healthz`. There is no separate frontend to host and no
second port. Production deployment is therefore mostly "put a good reverse proxy
in front of port 5212."
- [Reverse proxy](#reverse-proxy) - [Caddy](#caddy) · [nginx](#nginx) · [Serving filex under a sub-path](#serving-filex-under-a-sub-path)
- [HTTPS](#https)
- [PUBLIC_URL](#public_url)
- [Scaling / high availability](#scaling--high-availability)
- [What else runs beside filex](#what-else-runs-beside-filex)
- [Backup & restore](#backup--restore)
- [Health & monitoring](#health--monitoring)
- [Hardening checklist](#hardening-checklist)
- [See also](#see-also)
---
## Reverse proxy
Terminate TLS at a reverse proxy and forward **`/`** to `filex:5212` (Docker) or
`127.0.0.1:5212` (binary) - or a path of a domain you share with other things,
see [Serving filex under a sub-path](#serving-filex-under-a-sub-path). filex speaks plain HTTP internally - don't try to make
it terminate TLS itself. Whatever proxy you pick must:
- **Set `FILEX_PUBLIC_URL`** to the external `https://…` URL (see
[PUBLIC_URL](#public_url)) - this is on filex, not the proxy, but it only makes
sense once you know the public hostname.
- **Pass the real client IP** as `X-Real-IP` and/or `X-Forwarded-For`. filex reads
it for the **audit log**, the **file-drop rate limiter** and the
[sign-in attempt limit](CONFIGURATION.md#sign-in-attempt-limits) - without it
every request looks like it came from the proxy, and one person's wrong
passwords lock out everybody. filex believes the header **only from a
trusted proxy**: `FILEX_TRUSTED_PROXIES`, by default `auto` - this machine
(a proxy in front of the binary on `127.0.0.1:5212`) and, in a container,
the other containers on its network; never a Docker gateway, never the LAN.
A proxy **on the host** in front of filex's container (it arrives from the
gateway), a proxy **on another machine**, one that reaches filex over
`100.64.0.0/10` (Tailscale, Cloudflare WARP) and a CDN in front of your proxy
must be listed (`auto,
`) - the **Sign-in security** page names such
a peer and offers to add it ([Trusted proxies](CONFIGURATION.md#trusted-proxies)).
Since 0.57 the variable is a [seed](CONFIGURATION.md#variables-that-seed-a-stored-setting):
the first start writes it into that page's list, and from then on the saved
list is the one used - change it there, or run
`filex settings reseed login.trusted_proxies` after changing the variable.
- **Allow large request bodies** (e.g. `5G`) and **long timeouts** (~600 s) for
uploads. For **S3** storages the browser PUTs multipart chunks straight to the
bucket and only the small init/finalize JSON transits the proxy - but
**local / SFTP / WebDAV / FTP** uploads stream the whole file through it, so the
limits have to be generous.
- **Allow WebSocket / SSE upgrades - on `/api/ws` above all.** That is the
socket an open explorer runs on, and an explorer that has one **does not
poll**: the 12 s re-listing is the fallback for a socket that failed, not the
normal mode. Scope the upgrade to `/api/ai/mcp` alone and every browser
quietly degrades to a folder that refreshes twice a minute - which is exactly
the symptom "I upload a file and it shows up ten minutes later"
([REALTIME.md](REALTIME.md)). ⚠ The same-origin, cookie-authenticated upgrade
is **origin-checked**, so the proxy must pass the real `Host` header through.
The MCP endpoint at `/api/ai/mcp` is a long-lived streamable-HTTP transport
(POST for requests, GET to open the SSE stream) and needs the same;
download/upload streaming benefits from unbuffered proxying too.
- **gzip/zstd the admin SPA** and JSON responses (the hashed Vite assets are
large; filex already sets long `Cache-Control` on them).
### Caddy
The bundled config - [`deploy/compose/Caddyfile`](../deploy/compose/Caddyfile) -
already does all of the above and gets HTTPS for free. A standalone equivalent:
```caddy
files.example.com {
encode zstd gzip
# Large uploads: local/SFTP uploads stream through here (S3 multipart
# PUTs go browser→bucket directly, so only init/finalize JSON transits).
request_body {
max_size 5GB
}
# WebSocket/SSE (the MCP stream at /api/ai/mcp) upgrade automatically.
# Caddy also adds X-Forwarded-For / -Proto / -Host on its own.
reverse_proxy 127.0.0.1:5212 {
header_up X-Real-IP {remote_host}
}
}
```
That's the whole file. Caddy obtains and renews the certificate automatically
(see [HTTPS](#https)). In the Compose stack the upstream is `filex:5212` over the
internal Docker network instead of `127.0.0.1:5212`.
### nginx
A complete server block. The `map` at the top turns the `Upgrade` header into the
right `Connection` value so WebSocket/SSE upgrades work. ⚠ On a public port
other than 443/80, pass `$http_host` instead of `$host` (which drops the port):
S3 signatures, WebDAV `MOVE`/`COPY` and the realtime socket's origin check
compare against the `Host` the client sent.
```nginx
# /etc/nginx/conf.d/filex.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
http2 on;
server_name files.example.com;
ssl_certificate /etc/letsencrypt/live/files.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/files.example.com/privkey.pem;
# Large uploads (local/SFTP/WebDAV/FTP storages stream through the proxy).
client_max_body_size 5g;
# Compress the admin SPA + API JSON (hashed assets are already cached long).
gzip on;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml;
location / {
proxy_pass http://127.0.0.1:5212;
proxy_http_version 1.1;
# Real client IP - used for the audit log + file-drop rate limit.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket / SSE upgrade.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long timeouts for big uploads/downloads; don't buffer them to disk.
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_request_buffering off;
}
# The realtime socket. Without this block an open explorer never gets a
# change frame and falls back to re-listing every 12 s.
location /api/ws {
proxy_pass http://127.0.0.1:5212;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 3600s;
}
# The MCP stream needs unbuffered, long-lived proxying so SSE tokens flush
# immediately instead of piling up in nginx's buffer.
location /api/ai/mcp {
proxy_pass http://127.0.0.1:5212;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
# Redirect http → https.
server {
listen 80;
server_name files.example.com;
return 301 https://$host$request_uri;
}
```
### Serving filex under a sub-path
filex can live under a path of a domain it shares with other things -
`https://example.com/filex/` - instead of on a host of its own. Tell filex the
path, and have the proxy **pass the full path through**:
```sh
FILEX_PUBLIC_URL=https://example.com/filex # its path is the base; or set
# FILEX_BASE_PATH=/filex # explicitly (they must agree)
```
The startup log says which base is in effect
(`http: serving under a base path base_path=/filex …`). Every route, the web
app, share links, e-mails, the OIDC callback, WebDAV (`/filex/dav/`), path-style
S3 (`/filex/s3/`) and the realtime socket (`/filex/api/ws`) then live under it,
and **nothing outside it** reaches filex: `/api/…` on the host's root is a
plain 404. `/healthz` is the one exception - it answers at the root too, for
container health checks. The rules for the value and what it changes are in
[CONFIGURATION.md → Base path](CONFIGURATION.md#base-path).
⚠⚠ **The proxy must NOT strip the prefix.** filex receives
`/filex/api/files/manager` and takes `/filex` off itself; a proxy that strips
it sends `/api/files/manager`, which filex (correctly) refuses as outside its
base. In Caddy that is `handle`, **not** `handle_path`; in nginx a `proxy_pass`
**without** a URI part (`http://127.0.0.1:5212`, not `…:5212/`).
**Caddy** - inside the site block of `example.com`, next to whatever else it
serves:
```caddy
example.com {
encode zstd gzip
request_body {
max_size 5GB
}
# filex under /filex. `handle`, NOT `handle_path`: filex needs the prefix.
# The matcher names /filex itself too - filex answers it with a redirect
# to /filex/ - and not /filexsomething.
@filex path /filex /filex/*
handle @filex {
reverse_proxy 127.0.0.1:5212 {
header_up X-Real-IP {remote_host}
}
}
# …the rest of example.com
handle {
respond "the rest of the site" 200
}
}
```
**nginx** - in the `server` block of `example.com` (the `map` from the
[nginx](#nginx) example above is needed too):
```nginx
# filex under /filex. ⚠ `proxy_pass` has NO URI part: nginx then passes
# the request's path unchanged. `proxy_pass http://127.0.0.1:5212/;`
# (trailing slash) would strip /filex, and filex would answer 404.
# ⚠ `$http_host`, not `$host`: `$host` drops the port, and on a port other
# than 80/443 that breaks three things that compare against the Host the
# client sent - S3 signatures (SignatureDoesNotMatch), a WebDAV MOVE/COPY
# Destination (502) and the realtime socket's origin check (403).
location = /filex {
return 301 /filex/;
}
location /filex/ {
proxy_pass http://127.0.0.1:5212;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
client_max_body_size 5g;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_request_buffering off;
}
# The realtime socket and the MCP stream, unbuffered and long-lived -
# the same two blocks as above, under the base.
location /filex/api/ws {
proxy_pass http://127.0.0.1:5212;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 3600s;
}
location /filex/api/ai/mcp {
proxy_pass http://127.0.0.1:5212;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_buffering off;
proxy_read_timeout 3600s;
}
```
**Traefik / a Kubernetes Ingress** - route ``PathPrefix(`/filex`)`` to filex and
add **no** `StripPrefix` middleware; the Helm chart does this when you set
`basePath` (see [INSTALLATION.md → Kubernetes](INSTALLATION.md#kubernetes-helm)).
What else to know:
- **Clients** take the base as part of the server address:
`https://example.com/filex` in the desktop app's sign-in field and in
`filex client login --url`, `https://example.com/filex/dav/` for WebDAV,
`https://example.com/filex/s3` as a path-style S3 endpoint (the connection
pages print all of these filled in). A dedicated S3 host
(`FILEX_S3_DOMAIN`) is served at its own root and is not affected.
- **Cookies** are scoped to the base (`Path=/filex`), so the other
applications on the host do not receive filex's session.
- **An explorer embedded in another app** keeps pointing its `apiBase` at the
server root - `https://example.com/filex` - see
[INTEGRATION.md](INTEGRATION.md#serving-filex-under-a-sub-path).
- **Moving an existing install** under a base (or out of one) changes the
address of everything, including the installed web app's identity: people
who installed it install it again, and links already sent point at the old
address. A proxy can keep the old addresses alive with a redirect.
- **Upgrading from a setup whose proxy stripped the prefix.** Before 0.47.0
filex had no base path, and a `FILEX_PUBLIC_URL` with a path only changed the
links it built; a proxy that stripped the prefix got share links working
and the web app broken. From 0.47.0 on that path **is** the base: switch the
proxy to pass the full path (`handle` instead of `handle_path`).
---
## HTTPS
Always terminate TLS at the proxy and keep filex plain-HTTP behind it.
- **Caddy** issues and renews certificates automatically via Let's Encrypt /
ZeroSSL. Just point DNS at the host and open ports **80 and 443** so the
ACME challenge succeeds. Nothing else to configure.
- **nginx** pairs with **certbot**: `certbot --nginx -d files.example.com` writes
the `ssl_certificate` lines and sets up a renewal timer. Or terminate at a
managed load balancer and forward plain HTTP to filex.
- **Kubernetes**: terminate at the Ingress with **cert-manager** (a `ClusterIssuer`
+ a `tls` block / annotation on the Ingress). The Helm chart's `ingress.*` values
wire this up - see [INSTALLATION.md → Kubernetes](INSTALLATION.md#kubernetes-helm).
Whichever you use, the external scheme must be `https` and must match
`FILEX_PUBLIC_URL`.
---
## PUBLIC_URL
`FILEX_PUBLIC_URL` (default `http://localhost:5212`) is **the externally
resolvable URL users open in a browser** - e.g. `https://files.example.com`,
or `https://example.com/filex` for filex under a
[sub-path](#serving-filex-under-a-sub-path) (its path is then the base).
Behind a proxy this is the proxy's public hostname, **not** the internal
`filex:5212`.
filex bakes this value into things it can't recompute from an incoming request:
- **Share and file-drop links** (`/s/…`, `/d/…`) - the URLs handed to recipients.
- **The OIDC redirect** - the callback is `/api/auth/oidc/callback`; it
must match what's registered at your IdP.
- **OnlyOffice fetch/callback** (with a Document Server connected) - the
Document Server fetches the file and posts edits back **server-to-server**
using filex's public URL.
A wrong value fails quietly in production: share links point at `localhost` and
404 for everyone else, SSO login bounces to an unreachable/invalid redirect, and
OnlyOffice **saves fail** because the Document Server can't reach the callback.
Set it to the real HTTPS URL and keep it in sync with your DNS and IdP config.
> The binary/container listens on `FILEX_LISTEN` (default `0.0.0.0:5212`);
> `FILEX_PUBLIC_URL` is a separate, public-facing value. They're almost never the
> same behind a proxy.
---
## Scaling / high availability
**Start with one vertically-scaled node.** filex has no built-in clustering or
leader election; a single instance backed by PostgreSQL handles real teams
comfortably. Reach for multiple replicas only when you actually need them, and
know the constraints first.
**The database is the gate.** The default **SQLite** store is a single file with
a process-level lock - it's fine for one node but **cannot** be shared by
replicas. For more than one filex instance you must move the database off SQLite:
```bash
FILEX_DB_DRIVER=postgres
FILEX_DB_DSN=postgres://filex:…@db:5432/filex?sslmode=require
```
PostgreSQL is the recommended production driver. MySQL/MariaDB is supported too -
every engine's migrations, schema and first-install writes are exercised in CI
(see **[DATABASES.md](DATABASES.md)**), which they were not before v0.38.0.
**The queue must be shared too.** With no `FILEX_QUEUE_DRIVER` the queue follows
the database and lives in it. For multiple workers/nodes point it at Redis, or
at a Postgres of its own:
```bash
FILEX_QUEUE_DRIVER=postgres # uses SELECT … FOR UPDATE SKIP LOCKED
# or
FILEX_QUEUE_DRIVER=redis
FILEX_QUEUE_DSN=redis://redis:6379/0
```
⚠ **Both drivers had a bug worth knowing about if you are upgrading.** Before
v0.34.0 the **postgres** driver could never claim an operation at all - every
type-filtered dequeue failed with `42P18` - so on `FILEX_QUEUE_DRIVER=postgres`
nothing in the queue ever ran: no antivirus scans, no content extraction, no
async copy/move/delete. And the **redis** driver ignored `Priority`, so an
interactive operation queued behind a bulk import waited for all of it. Both
are fixed. The redis pending set changes from a LIST to a SORTED SET, converted
at startup with every queued operation preserved - ⚠ after which **downgrading
is not supported**.
**`/data` is per-instance.** Beyond the SQLite DB, each node keeps five things
on local disk under `FILEX_DATA_DIR`:
- `search.bleve/` - the full-text index (its embedded store takes an **exclusive
file lock**, so it can't be shared read-write either),
- `thumbs/` - the thumbnail cache (released when a node is purged, and swept
for orphans every `FILEX_THUMBS_SWEEP_INTERVAL`),
- `cache/` - the read cache for slow storages,
- `uploads/` - staging for chunked and resumable uploads. ⚠ Not rebuildable
while a transfer is in flight: until it commits, this is the file's only copy,
- `ssh/` + `ftps/` - the SFTP host keys and the FTPS certificate, generated on
first boot. ⚠ Per-node local `/data` means each replica generates its **own**,
so a client load-balanced across them sees the host key change from
connection to connection. Put the protocol listeners on one node, or hand
every replica the same key material.
⚠ One more thing under `FILEX_DATA_DIR` is **not** rebuildable and not
shared: the data an app keeps of its own (`app-plugins/data/`, a link
shortener's links). Behind several replicas set `FILEX_APP_DATA_DSN` to a
PostgreSQL database of the apps' own, so every replica reads the same
([APP-PLUGINS.md → An app's own data](APP-PLUGINS.md#an-apps-own-data)).
The first three are **rebuildable** (see [Backup & restore](#backup--restore)), so for a
multi-replica deployment you have two honest options:
1. **Per-node local `/data`** (an emptyDir-style volume each) - every replica
builds its own search index and thumbnail cache. Simplest; costs some
duplicate work and each node's index lags until it syncs.
2. **A shared RWX volume** for `/data` - only safe if you're confident about the
file-lock semantics of your shared filesystem; when in doubt prefer option 1.
Sessions and API tokens are validated against the database, so a shared Postgres
keeps logins working across replicas without sticky sessions - just make sure
every replica shares the **same DB, the same secrets, and the same
`FILEX_PUBLIC_URL`**. Storage backends (S3, SFTP, …) are external and already
shared by definition.
⚠ The [sign-in attempt limit](CONFIGURATION.md#sign-in-attempt-limits) counts
per instance: its settings reach every replica within a minute, but each
replica keeps its own counters, so N replicas allow up to N times the limit, and
a lock one replica places is not seen by the others until they restart.
> **Bottom line:** filex scales *up* trivially and scales *out* to a Postgres +
> Redis/Postgres-queue topology with per-node (or carefully-shared) search/thumb
> state. It does not yet ship a turnkey active-active cluster.
---
## What else runs beside filex
filex is one process, but four optional things run **next to** it, each as its
own container or service, each reached over the network:
| Service | What it gives you | Configured |
|---|---|---|
| **ClamAV** (`clamav/clamav`) | virus scanning of every file written | `FILEX_CLAMAV_ADDR=clamav:3310` seeds it; after the first boot, *Settings → Protection* |
| **ONLYOFFICE Document Server** | optional, and not in the default install since 0.57: an office engine (office thumbnails and the apps' office conversions; no image carries LibreOffice) and in-browser editing. The [Office editor app](ONLYOFFICE.md#the-office-editor-app) edits Word, Excel and PowerPoint - and from its 0.4.0 the older formats and `.csv` - in the browser and brings an [office engine](ONLYOFFICE.md#the-office-engine-without-a-document-server) of its own, so it needs none; connect one for what that engine does not do yet | *Settings → External services*, applies with no restart |
| **drawio** | diagram editing | same |
| **S3 server** (`versity/versitygw`) | an S3 storage on your own disk | the `versitygw` Compose profile or `versitygw.enabled` in Helm; [STORAGE.md → A local S3 server](STORAGE.md#a-local-s3-server) |
⚠ **The filex images ship no scanner.** ClamAV plus its signature database is
close to a gigabyte, so it is not baked in - which makes "run clamd next to
filex" the normal shape rather than a fallback. Files are sent with clamd's
`INSTREAM`, streamed down the same connection the command went out on, so the
two containers need **a network route and not a shared filesystem**. There is a
ready `clamav` profile in
[`deploy/compose/docker-compose.full.yml`](../deploy/compose/docker-compose.full.yml);
on Kubernetes it is a Deployment plus a Service, with the address handed to
filex through the chart's `antivirus:` block:
```yaml
antivirus:
mode: daemon # blank works too - a non-blank address seeds daemon
address: "clamav:3310"
```
⚠ Like every other zero-touch value in that chart these are **first-boot
seeds**, not live switches: once the install has a stored row, the switch, the
mode and the address are edited in *Settings → Protection* and editing the
values file does nothing. Details, including what an unreachable daemon does
(it fails the scan loudly; it never marks a file clean), are in
[PROTECTION.md](PROTECTION.md#antivirus-clamav).
⚠ The three external services are read from the database **on every use**, so
an edit in the admin UI reaches the running process. A service named in the
environment or `config.yaml` takes their values while nothing is configured in
it (a seed, like every other setting a variable gives a first value to) and is
labelled `env_managed` in the UI; an admin edit to it stands across restarts,
and `filex settings reseed --external` writes the environment's values again.
---
## Backup & restore
Three things hold real state; back up **all three**:
1. **The database** - the SQLite file (`/instance.sqlite`) or your
PostgreSQL/MySQL. This is the source of truth for users, storages,
permissions, shares, tags, the file-node cache, and audit history.
2. **The storage backends** - the actual file bytes. filex doesn't own these; a
local disk, an S3 bucket, an SFTP server each have their own backup story.
Back them up where they live.
3. ⚠⚠ **`FILEX_SECRET_KEY`** - the key that seals the credentials filex has to
be able to *recover* rather than merely compare. S3 access keys are the case
that hurts most: SigV4 verifies a request by recomputing an HMAC chain from
the secret, so it cannot be hashed the way an API token is, and it is stored
sealed with AES-GCM under this key. The same key also seals a remote storage
plugin's token, an app's secret settings and signing authority, and the copy
of each share link's PIN that lets its creator read it back (the link itself
keeps working without it).
**A restored database without the matching key is a database whose S3 access
keys no longer verify** - every `aws s3`, `rclone` and `restic` job pointed
at this server stops with a signature error, and nothing in that error says
why. Keep it wherever you keep the database credentials, and do not rotate it
casually. Losing it is not recoverable; the fix is minting every access key
again.
**Rebuildable, so backing them up is optional:**
- `search.bleve/` - regenerate with `POST /api/admin/search/rebuild` (admin) or
by deleting it and restarting. ⚠ A rebuild needs room for a **second copy**
of the index while it runs (roughly 2x the current size in additional space);
filex refuses and keeps serving the old one when the disk cannot take it.
Sibling directories named `search.bleve.rebuilding` / `search.bleve.old` are
a rebuild in flight or an interrupted one - they are cleaned up on the next
start, and neither is worth backing up.
- `thumbs/` - regenerate with `filex thumb backfill` (add `--retry-failed` to
re-run failed rows, `--storage ` to scope it).
**Rebuildable, and you should actively EXCLUDE it:**
- `cache/` - prepared copies of big files on slow storage, and folder-share ZIPs
(`cache/sharezips/`, moved there from `/sharezips` on first start of
v0.19.1+). These are the largest throwaway objects filex produces: one archive
of a shared folder is as big as the folder. Backing the directory up puts them
in every snapshot, every off-site copy and every restore, for bytes that are
regenerated on demand. Exclude `/cache` (and, for older backup
configs, `**/sharezips/**`).
**Restore** = restore the database, restore/attach the storage backends, start
filex. If you skipped the search index and thumbnail cache in your backup, filex
serves immediately and you trigger a rebuild/backfill to repopulate them.
**Upgrades run migrations on startup.** Pull the new image or binary and restart -
schema migrations apply automatically. **Back up the database before upgrading.**
To inspect or roll back one step manually:
```bash
filex migrate status # what's applied / pending
filex migrate down # roll back exactly one migration
# in Docker: docker exec filex filex migrate down
```
---
## Health & monitoring
- **Liveness / readiness:** `GET /healthz` returns `200` with `{"status":"ok"}`
and needs no authentication. Wire it to your load balancer health check and to
Kubernetes liveness+readiness probes. The Docker image's built-in `HEALTHCHECK`
already polls it every 30 s.
- **Error reporting:** set `FILEX_SENTRY_DSN` (and optionally
`FILEX_SENTRY_ENVIRONMENT=production`) to ship crashes/errors to Sentry or a
self-hosted GlitchTip. Empty DSN = off. See
[CONFIGURATION.md → Error reporting](CONFIGURATION.md#error-reporting).
- **Structured logs:** `FILEX_LOG_FORMAT=json` (with `FILEX_LOG_LEVEL=info|debug|…`)
emits JSON lines for Loki / ELK / Datadog ingestion. Each request's `msg=http`
line names its caller (`user_id`, `token_id`, `tenant`) and, on the file
manager, its verb (`action`) - never the query string; see
[CONFIGURATION.md → Logging](CONFIGURATION.md#logging).
- **Operational surfaces** (admin session/token): the admin **Dashboard**, plus
queue stats (`/api/admin/queue/stats`) and storage **sync-runs / drift**
(`/api/admin/storages/{id}/sync-runs`, `…/drift`) let you watch worker health
and detect a backend that's drifting from the cache.
---
## Hardening checklist
- [ ] **Strong, unique secrets.** Generate them (`openssl rand -hex 24`) and keep
them out of version control - never commit `.env`. With a Document
Server, `ONLYOFFICE_JWT_SECRET` must be identical on filex and on it;
the OIDC client secret must match your IdP.
- [ ] **HTTPS everywhere.** TLS terminated at the proxy, `FILEX_PUBLIC_URL` on
`https://`.
- [ ] **Restrict CORS if you embed.** The default `FILEX_CORS_ALLOWED_ORIGINS=*`
is convenient but permissive. If you embed the web component (`/embed.js`)
or the explorer from specific host apps, pin the list to those origins.
The same list is the only one that lets another origin **change** things
with a visitor's session (cross-site request forgery is refused for every
origin not on it, `*` included); prefer exact hosts over a
`https://*.example.com` pattern, which trusts every subdomain. See
[CONFIGURATION.md → Requests from other origins](CONFIGURATION.md#requests-from-other-origins).
- [ ] **Keep filex's session off hosts you do not control.** `FILEX_COOKIE_DOMAIN`
shares the session cookie with every subdomain of that domain; filex
refuses changes those pages send through it, but they still receive the
cookie. Leave it empty unless tenants or sibling apps need it.
- [ ] **Read-only storages where writes aren't needed.** Set `read_only: true` on
archive/replica mounts so uploads, renames, moves and deletes are refused
(see [STORAGE.md → Read-only mounts](STORAGE.md#read-only-mounts)).
- [ ] **RBAC per storage.** Enable `rbac_enabled` on storages that shouldn't be
visible to every authenticated user; grants then gate access (see
[RBAC.md](RBAC.md)).
- [ ] **Least-privilege API tokens.** Scope tokens to only the verbs they need
(`read` / `write` / `delete` / `mcp` / `admin`) and use **`root:`
confinement** to lock a token to one sub-folder. Confinement is enforced in
the backend, so a confined token can't escape its root even if it knows
other paths. The verbs hold on every surface the token reaches - the
explorer's routes, the MCP tools, WebDAV, SFTP, FTPS, and the S3 keys and
NFS exports minted from it ([RBAC.md](RBAC.md#api-tokens-verbs-on-every-surface)).
Installing a plugin and making an administrator are never a token's: an
administrator signed in to the panel does them.
- [ ] **Least-privilege roles.** Give people the built-in User or Viewer role,
or a custom role that holds only what their work needs - each file
action, each kind of sharing, each protocol and each admin area is its
own permission ([PERMISSIONS.md](PERMISSIONS.md)).
- [ ] **TOTP 2FA for admins.** Enable it per admin in **user settings →
Security** (the dialog behind the avatar menu).
- [ ] **Keep the proxy the sole ingress.** If you use proxy-header auth
(`auth.header_proxy`), filex trusts identity headers like `X-Auth-Email` -
so bind filex to localhost / the internal network (`FILEX_LISTEN` on
`127.0.0.1:5212`, or don't publish the container port), set `trusted_ips` to
the proxy, and make sure **nothing can reach 5212 except the proxy**, or a
client could spoof those headers.
- [ ] **Trust only your own proxies.** `FILEX_TRUSTED_PROXIES` defaults to
`auto`: this machine and, in a container, the other containers on its
network - never the gateway, never the LAN. A trusted peer can name any
address in `X-Forwarded-For`, one on the sign-in IP allow-list included,
so add only your own proxy's address next to it (`auto, `), and
trust a Docker gateway only when the published port is reachable by
your proxy alone (`127.0.0.1:5212:5212`, or firewalled). Check
**Sign-in security → Trusted proxies**: what `auto` resolved to, and any
peer that sends forwarded addresses without being trusted
([Trusted proxies](CONFIGURATION.md#trusted-proxies)). The variable only
seeds that list at the first start (since 0.57): on a running install,
the list on the page is the one that counts.
- [ ] **Put your own address on the sign-in allow-list.** The
[sign-in attempt limit](CONFIGURATION.md#sign-in-attempt-limits) spares
nobody, the first administrator included; an address on
`login.ip_allowlist` (Admin → Sign-in security) is the way back into a
locked account.
- [ ] **Disable demo mode.** `FILEX_DEMO_MODE` is `false` by default - keep it
that way in production (it auto-fills demo credentials on the login page).
---
## See also
- [INSTALLATION.md](INSTALLATION.md) - get an instance running first
- [CONFIGURATION.md](CONFIGURATION.md) - every `FILEX_*` variable
- [STORAGE.md](STORAGE.md) - storage backends, read-only mounts, sync
- [RBAC.md](RBAC.md) - per-storage / per-file access control
- [SSO.md](SSO.md) · [ONLYOFFICE.md](ONLYOFFICE.md)