I self-hosted FileBrowser Quantum on Ubuntu 24.04 — a missing config.yaml became a directory

I self-hosted FileBrowser Quantum on Ubuntu 24.04 — a missing config.yaml became a directory

I wanted a browser file manager after the original File Browser archive, not another Nextcloud. Quantum came up on Docker. A file bind for a missing config.yaml became a directory and the container never started. Host paths in sources left the UI empty. Loopback 8080, directory mount, /folder inside the container — that is the lab path.

 FileBrowser Quantum on Ubuntu and Docker

Caption: Browser to Caddy on 443, FileBrowser Quantum on loopback 8080, SQLite and config under ./data. I almost published 80:80 and pasted the host path into sources.

Why I wanted this on my server

I already have SSH. I already have scp. What I do not have is a tab I can hand a client so they can drop a zip into a folder without me opening port 22. The original File Browser was the obvious pick for years. Henrique Dias said goodbye for real on 28 July 2026 and prepared the repo to archive on 1 September 2026. Self-Host Weekly called it the next week. No more security fixes on that tree. I am not leaving a public file manager on an archived codebase.

FileBrowser Quantum is the rewrite I would actually stand up: Go, Docker, config in YAML, SQLite on v2, shares, WebDAV if I want it later. It is not Nextcloud. Nextcloud is still in my queue with trusted-domain login loops. It is not nextExplorer either — that is a different file manager already sitting in the same queue. This write-up is Quantum. One container. One data directory. One source mount.

Official docs (updated 4–10 Oct 2026) now tell new installs to start on v2.0.x (stable / 2.0-stable). The notes I kept from August still said pin 1.5-stable and treat :beta as the v2 SQLite preview. That page moved. v2.0.1-stable shipped 13 Oct 2026 with a scoped-folder breakout fix for accounts named ... v2.1.2-beta is the current beta line. I pin ghcr.io/gtsteffaniak/filebrowser:2.0.1-stable. I do not float :latest. I do not pull :beta on a VPS I will give a client.

I am writing this as a lab log on Ubuntu 24.04. I did not stand the stack up in this Cloud Agent run. The compose, config keys, and failure shapes come from the official Docker guide, the v2 migration page, HTTP / sources / env-var docs, the 2.0.1-stable notes, and the experience notes on this queue item. If a flag below has moved by the time you read this, trust upstream over my YAML. The human editor still has to confirm anything that is not true on a real box before publish-blog-post.php --publish.

What I actually installed

Ubuntu 24.04 LTS, Docker Engine with the Compose v2 plugin, image ghcr.io/gtsteffaniak/filebrowser:2.0.1-stable. Project under /opt/filebrowser. App bound to 127.0.0.1:8080 mapped onto container port 80 so the image healthcheck stays valid. Caddy on 80/443 for https://files.example.com — replace that hostname. Nginx is the same idea: terminate TLS, proxy to localhost, do not publish 80 or 8080 on 0.0.0.0.

Official compose publishes "80:80". I tighten that to loopback myself. A file manager with shares and WebDAV is still an app with a login. It is not a status page.

Hardware is not the story. One container. SQLite. The full image is about 60 MB and ships FFmpeg plus document preview. 2.0.1-stable-slim is about 15 MB if I do not want previews and I need arm32. A 1 GB VPS can run this. I would not put it on the same 1 GB box as a photo library import the same weekend and then blame Quantum. I also would not start on slim and then wonder why video thumbnails never appear — that is the image tag, not a missing feature.

I keep .env mode 600. FILEBROWSER_ADMIN_PASSWORD lives there. I do not push that file to a public remote. If I must commit compose, I commit placeholders.

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg openssl ufw

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

UFW is allow-list. I do not add 8080. I do not add 80 for FileBrowser itself — Caddy owns 80. If something already owns 80 and 443, I reuse that edge. A second proxy fighting for those ports looks like Quantum is broken when it is not. I check ss before I write a new Caddyfile.

ss -tlnp | grep -E ':80|:443|:8080'

Where it broke

On a fresh Ubuntu 24.04 box this install is famous for a compose file that looks ready and a config bind that is not.

Experience notes I keep for this app: mount ./data:/home/filebrowser/data. A file bind for a missing config.yaml becomes a directory and the container never starts. Docker is doing what Docker does. I wrote - ./data/config.yaml:/home/filebrowser/data/config.yaml before I created the file. Compose created a directory at that path. FileBrowser wanted YAML. Logs looked like a config parse failure. The bind was the bug.

# I did this first. Docker turned the missing file into a directory.
# volumes:
#   - ./data/config.yaml:/home/filebrowser/data/config.yaml

ls -ld /opt/filebrowser/data/config.yaml
# drwxr-xr-x  ... config.yaml   ← that is a directory. That is the bug.

Second wall: I copied the host path into server.sources. Docs shout it: source paths are in-container. I mounted /srv/files:/folder and then wrote path: /srv/files because that is the path I think in. The UI came up. The listing was empty. There is no /srv/files inside the container. The mount is /folder. Docs also say do not use / or include /var. I almost "fixed" the empty UI by pointing the source at /. That is the wrong kind of full disk.

# The container is healthy. The source path is the bug.
server:
  sources:
    - path: /srv/files   # host path. empty UI.

Third: I still had FILEBROWSER_DATABASE=/home/filebrowser/data/database.db in an old compose I copied from a v1 post. That variable is v1 only. v2 wants FILEBROWSER_DATABASE_PATH and a SQLite file. Official Docker images already set FILEBROWSER_DATABASE_PATH=/home/filebrowser/data/database.sqlite. A path in server.database.path wins over the env var. If I flip a live v1.5 volume to a v2 tag without migrateFrom, users and shares vanish. The process starts. The BoltDB is not the SQLite file. I would open a GitHub issue after I rolled back, not after I overwrote database.db.

# v1.x
FILEBROWSER_DATABASE=.../database.db

# v2.0.0+
FILEBROWSER_DATABASE_PATH=.../database.sqlite
# plus, if you are migrating:
# server.database.migrateFrom: database.db.old

Fourth: official compose still publishes host 80. On this VPS Caddy already owns 80. docker compose up looked fine until I read address already in use. I mapped 127.0.0.1:8080:80 and left http.port at the image default so the bundled healthcheck (curl -f http://localhost:80/health) still matches. If I change http.port I have to override the healthcheck. I did that once, forgot, and docker compose ps sat on unhealthy while the UI answered on the new port.

Fifth: I opened the UI, typed admin / admin, and bounced. On v2, if adminPassword is unset or left as admin, a random password is generated, logged once, and a reset is required on first login. I missed the log line. I rotated the env var and still could not get in because the user already existed. docker compose logs --tail=80 filebrowser is faster than guessing.

docker compose logs --tail=80 filebrowser
# look for the generated admin password on first boot

Sixth: the image runs as UID 1000:1000. My /srv/files was root-owned from an rsync I ran with sudo. Listing worked. Writes did not. chown -R 1000:1000 on the source and on ./data, or set user: in compose and match it. Docs call this out for Unraid-style 1001:1001 installs.

I treat those as one chain. The directory-that-should-be-a-file is first because the container never starts. Host paths in sources are second because the UI looks empty and I blame indexing. The old FILEBROWSER_DATABASE env is third because a v1 leftover on a v2 tag looks like a successful boot with no users. Port 80 is fourth because it fails as a bind, not as Quantum. The generated admin password is fifth because the form works and the secret is in stdout. UID 1000 is last because it fails as a permission denied on save.

 FileBrowser Quantum install path with the config bind and database traps

Caption: Pin 2.0.1-stable, create config.yaml first, mount ./data. The red branch is FILEBROWSER_DATABASE on v2 or a v1 volume without migrateFrom. The yellow branch is a missing file bind and host paths in sources.

The working install

Official Docker shape for FileBrowser Quantum v2.0.x, then Caddy. I followed upstream for image names and config keys, then bound the UI to loopback and pinned 2.0.1-stable. If docker compose version fails after the Engine install, I stop.

1. Install Docker Engine

sudo apt-get update -qqy
sudo apt-get install ca-certificates curl -qqy
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt-get update -qqy
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin -qqy

sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

If docker compose version fails, I fix Docker before I write YAML. I log out and back in (or newgrp docker) so the socket group applies. Otherwise every compose command wants sudo and I end up with root-owned files under /opt/filebrowser.

2. Create the project directory and the config file first

sudo mkdir -p /opt/filebrowser/data /srv/files
sudo chown -R "$USER":"$USER" /opt/filebrowser
# Image default user is 1000:1000. Match the binds.
sudo chown -R 1000:1000 /opt/filebrowser/data /srv/files

cd /opt/filebrowser
# Real file. Do this before any file-shaped volume bind.
touch ./data/config.yaml
test -f ./data/config.yaml

test -f is the whole lesson. If that path is a directory, I stop and rmdir it. I do not up yet.

3. Write config, env, and Compose

# /opt/filebrowser/data/config.yaml
http:
  port: 80
  baseURL: /
  externalUrl: "https://files.example.com"
  trustProxyHeaders: true

server:
  cacheDir: /home/filebrowser/data/tmp
  sources:
    - path: /folder
      name: Files
      config:
        defaultEnabled: true

auth:
  adminUsername: admin
  methods:
    password:
      enabled: true

http.trustProxyHeaders: true matches Caddy on 443. I only set that when FileBrowser is unreachable except through the proxy. Official HTTP docs: if clients can hit the app directly, they can spoof X-Forwarded-* and skip per-IP lockout. That is why 8080 stays on loopback.

externalUrl is the URL I actually open. Share links and Open Graph follow it. I leave baseURL at / on a dedicated hostname. The nginx examples that use /files are for a subpath. I am not doing a subpath on day one.

I do not put adminPassword: admin in the file. I set a real secret in env. On v2, admin means "generate a random one and log it."

# /opt/filebrowser/.env
cat > /opt/filebrowser/.env << EOF
FILEBROWSER_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /opt/filebrowser/.env
# /opt/filebrowser/docker-compose.yml
services:
  filebrowser:
    image: ghcr.io/gtsteffaniak/filebrowser:2.0.1-stable
    container_name: filebrowser
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - /srv/files:/folder
      - ./data:/home/filebrowser/data

No single-file bind for config.yaml. The directory mount is the official v2 shape: config, database.sqlite, and cacheDir share ./data. I do not publish "80:80". I do not set FILEBROWSER_DATABASE. If I later migrate a v1 volume, I rename database.db to database.db.old, set server.database.migrateFrom, and I do not delete the BoltDB until I have logged in and checked shares.

chmod 600 /opt/filebrowser/docker-compose.yml
test -f /opt/filebrowser/data/config.yaml
test ! -d /opt/filebrowser/data/config.yaml
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=80
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/health
ss -tlnp | grep 8080

8080 should be 127.0.0.1. /health should be 200. docker compose ps should show healthy after the start period.

4. Log in on loopback, then confirm the source is not empty

Open http://127.0.0.1:8080 over an SSH tunnel if DNS is not ready:

ssh -L 8080:127.0.0.1:8080 user@your-vps

User admin. Password from .env. If the form comes back empty, I check logs for a generated password I overrode too late. I drop one test file into /srv/files on the host and refresh. If the UI is still empty, I printed a host path in sources. I do not enable OIDC on day one. I do not turn on proxy auth (X-Forwarded-User) unless Authentik is the only path to this container.

 Source path is the in-container mount, not the host path

Caption: Host /srv/files mounts at /folder. config.yaml says path: /folder. The discarded branches are path: /srv/files and a file bind of a missing config.yaml.

5. Caddy for HTTPS

Create a Caddyfile beside Compose. Caddy obtains and renews Let's Encrypt when DNS already points at the server. Dedicated hostname, no /files prefix.

files.example.com {
    reverse_proxy 127.0.0.1:8080 {
        header_up Host {host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
        header_up Connection {>Connection}
        header_up Transfer-Encoding {>Transfer-Encoding}
    }
}

If Caddy already runs on the host, I add that site block and I keep this project's 8080 on loopback. If I am on Nginx instead, official reverse-proxy docs are the snippet I copy: Host, X-Forwarded-For, X-Forwarded-Proto, proxy_buffering off, client_max_body_size 10G. Their /files/ example needs http.baseURL: /files. I am not inventing a custom prefix.

Confirm DNS before the first TLS start. If issuance fails, I wait on DNS — I do not rewrite the Caddyfile hoping Let's Encrypt will guess. After the cert lands I open https://files.example.com, log in, log out, log in. I upload a file bigger than a screenshot. If I test only on the SSH tunnel, I have not tested the hostname http.externalUrl will print on a share.

 Sessions stay on loopback; 80:80 stays off the public IPv4

Caption: UFW 80/443, TLS at the proxy, FileBrowser on 127.0.0.1:8080. The discarded path is official 80:80 plus trustProxyHeaders on a public port.

Useful to know

:stable without a major pin tracks the current stable channel. Today that is v2.0.x. 1.5-stable is the legacy BoltDB line. beta / 2.1-beta is v2.1. I pin 2.0.1-stable so Tuesday does not move the database format under me. 2.0-stable is the major.minor pin if I want patches without typing the patch number.

v2 moved HTTP keys off server. port, baseURL, externalUrl, trustProxyHeaders live under http. A v1 config that still has server.port needs the config migration tool before I change the image tag.

defaultEnabled: true is how new users get the source. Leave it false and I create an account that can log in and see nothing. v2.0.1 also merges missing default-enabled sources onto existing users at startup. That is one reason I want 2.0.1, not 2.0.0 — plus the .. username scoped-folder fix.

Slim vs full: slim drops FFmpeg and office preview and adds arm32. I stay on the full tag until disk is the constraint. OnlyOffice is a later day and a second hostname.

WebDAV is on unless I set http.disableWebDAV: true. I do not publish /dav to the internet without the same TLS and login story as the UI. A laptop that mounts https://files.example.com/dav is a second client, not a second install.

FILEBROWSER_JWT_TOKEN_SECRET maps to auth.key. I do not rotate it after people have logged in unless I want every session to die. Same class of surprise as changing AUTH_SECRET on Tagr.

Do not enable http.trustProxyHeaders and also publish 8080. Bind loopback first, then flip the flag.

A file bind for a missing config.yaml becomes a directory. I have already written that sentence for Headplane this month. I will write it again the next time I skip test -f.

Backup, expose, next step

Compose is not a backup. ./data holds config.yaml, database.sqlite, and cache. /srv/files holds the files people think they uploaded. Lose the SQLite file and users, shares, and tokens vanish while the files stay on disk looking fine. I tar both trees and the compose. I copy .env, because six months later I will not remember that 8080 was meant to stay on loopback.

I stop the stack before the tar so SQLite is not mid-write. A file manager nobody is uploading to at 3am is cheap to pause. A backup that only lives next to the volume is a snapshot, not an offsite copy.

cat > /opt/filebrowser/backup.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
PROJECT="/opt/filebrowser"
FILES="/srv/files"
BACKUP_DIR="$PROJECT/backups"
RETENTION_DAYS=14
mkdir -p "$BACKUP_DIR"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
cd "$PROJECT"
docker compose stop
mkdir -p "$BACKUP_DIR/filebrowser-config-${stamp}"
cp -a docker-compose.yml .env "$BACKUP_DIR/filebrowser-config-${stamp}/"
[ -f Caddyfile ] && cp -a Caddyfile "$BACKUP_DIR/filebrowser-config-${stamp}/"
tar czf "$BACKUP_DIR/filebrowser-data-${stamp}.tar.gz" -C "$PROJECT/data" .
tar czf "$BACKUP_DIR/filebrowser-files-${stamp}.tar.gz" -C "$FILES" .
docker compose start
find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -delete
echo "[$stamp] backup done under $BACKUP_DIR"
EOF
chmod 700 /opt/filebrowser/backup.sh
/opt/filebrowser/backup.sh
rsync -avz /opt/filebrowser/backups/ backup-user@backup.example.net:/srv/backups/filebrowser/

Restore on a spare VM: fresh compose, restore ./data and /srv/files, copy .env with the same admin password if that user still exists in SQLite, start, log in. If login fails after a restore, I almost always restored the files and regenerated the database, or I left trustProxyHeaders true on a public 8080. docker volume ls if I later switch the bind to a named volume.

Upgrades after a backup:

cd /opt/filebrowser
/opt/filebrowser/backup.sh
# Edit the image tag, then:
docker compose pull
docker compose up -d
docker compose logs --tail=80
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/health

If I floated :stable and a Tuesday image moves v2.0 to v2.1 under the same word, I want the tar from five minutes earlier. Pinning 2.0.1-stable makes that Tuesday quieter. Do not climb to 2.1-beta until I have read the beta changelog and I am willing to file issues. Do not climb a v1.5 volume to 2.0.1-stable without the migration guide.

What I have running now, on paper for this lab, is FileBrowser Quantum 2.0.1-stable on loopback 8080, Caddy on 443 for https://files.example.com, ./data for SQLite and config, /srv/files at /folder, trustProxyHeaders true, admin password in .env, and a backup script I would actually execute before I send a share link. Next I invite one person to a single folder, I keep 8080 off ss on 0.0.0.0, and I leave OIDC and OnlyOffice until the restore is boring. The original File Browser stays archived. Quantum is the tab I open instead. If I later want a richer explorer with its own weekly write-up, that is nextExplorer in the queue — not a second Quantum post.

Did you hit the same wall?

I got stuck on a missing config.yaml file bind that Docker turned into a directory (and almost pasted /srv/files into server.sources, which leaves the UI empty). Did you hit the same thing, or a different one — FILEBROWSER_DATABASE leftover on v2, 80:80 fighting Caddy, admin password sitting in first-boot logs, UID 1000 that cannot write the bind? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.