When Docmost says "Error uploading file to drive"

Tracing a misleading error message in Docmost back to a Docker bind mount permission issue, and a permanent fix using a custom entrypoint script.

When Docmost says "Error uploading file to drive"
Photo by David Travis / Unsplash

I was trying to upload a file attachment to a Docmost page and got hit with a generic error: "Error uploading file to drive." No stack trace, no permissions hint, no file path. Just a plain 400 Bad Request. The UI was equally unhelpful, showing the same message without any clue about what went wrong.

This post walks through how I traced the real cause and how I fixed it permanently.

The symptoms

A file upload to a Docmost page returned a 400 error. Here is the full log:

"context":"AttachmentService","msg":"Error uploading file to drive:"
"context":"AttachmentController","err":{"type":"BadRequestException","message":"Error uploading file to drive",...}

Two things stood out immediately. First, the AttachmentService log ends with a colon and nothing after it. That suggests the error object was logged but its string representation was empty. Second, the controller throws a BadRequestException with the same static message, which means whatever the real error was, it got replaced with a canned string.

The error is clearly being swallowed. Somewhere between the storage driver throwing an exception and the controller returning a response, the real error message is lost.


The environment

I run Docmost in Docker using docker compose. The setup is simple: a Postgres database, a Redis instance, and the Docmost app itself. File storage uses the default local driver, meaning uploaded files go to the container filesystem at /app/data/storage. That path is mounted as a bind mount from a directory on the host.

Docmost version is 0.96.0, pulled from the official Docker image. The container runs as user node (uid 1000), which is the default in the upstream image.

I have a custom Dockerfile on top of the official image that applies a few npm package patches. The docker-compose.yml had the build section commented out, so it was using the vanilla image directly.

Tracing the error

I grabbed the error log and sent it to DeepSeek V4 Flash. I am not a Node.js developer and I did not want to spend time reading compiled JavaScript inside a container. The AI had the full picture from the log alone.

It started by reading the compiled source from inside the running container. This is what it found in the uploadToDrive method inside attachment.service.js:

async uploadToDrive(filePath, fileContent) {
    try {
        await this.storageService.upload(filePath, fileContent);
    } catch (err) {
        this.logger.error('Error uploading file to drive:', err);
        throw new BadRequestException('Error uploading file to drive');
    }
}

The catch block logs err directly. If err is not a standard Error object, the NestJS Logger might serialize it as an empty string, which explains the colon with nothing after it in the log. Then it throws a new BadRequestException with a hardcoded message, completely burying the real cause.

The AI went deeper. It checked the storage.service.js file and found that upload delegates to a storage driver. Since no STORAGE_DRIVER env var was set, it defaults to local. The local driver's upload method looked like this:

async upload(filePath, file) {
    try {
        const fullPath = this._fullPath(filePath);
        if (file instanceof Buffer) {
            await fs.outputFile(fullPath, file);
        } else {
            await fs.mkdir(dirname(fullPath), { recursive: true });
            await pipeline(file, createWriteStream(fullPath));
        }
    } catch (err) {
        throw new Error(`Failed to upload file: ${err.message}`);
    }
}

For file uploads, the code creates the directory with mkdir and then pipes the stream to a write stream. The chain was clear: mkdir fails -> local driver wraps the error -> storageService.upload propagates it -> uploadToDrive logs and re-throws -> controller returns 400.

That pointed to a filesystem permission problem. The AI ran a quick check inside the container:

$ docker exec docmost-app ls -la /app/data/storage/
total 12
drwxr-xr-x 3 root root 4096 Apr 30 04:08 .
drwxr-xr-x 3 node node 4096 Sep  8 15:49 ..
drwxr-xr-x 3 root root 4096 Apr 30 04:08 <workspace-id>

The storage directory and its subdirectories were owned by root with permissions 755. The app runs as node (uid 1000), so it could read and traverse directories but could not create new files or directories inside them. The mkdir call was hitting EACCES.

Why were the permissions wrong?

The container had been running with a different configuration at some point -- perhaps before I added USER node to the custom Dockerfile, or perhaps an earlier version of the upstream image ran as root. When files were first written to the bind-mounted volume, they were created with root ownership. After switching to the node user, the app could no longer write to those directories.

Fun fact: existing files from earlier uploads were still readable because the r-x permission on directories was enough for reading. Only new uploads broke.

This also explains why the error was hard to debug. The app had been working fine for previous uploads. The breakage only showed up on new file writes, and the error message gave no hint about permissions.


The fix

The AI proposed both an immediate fix and a permanent one.

Immediate fix

Change the ownership of the storage directory to match the user the app runs as:

docker exec -u root docmost-app chown -R node:node /app/data/storage

This runs chown as root inside the container and changes ownership of the entire storage tree to node:node. I ran this and uploads started working again.

But this is a manual fix. If the container is recreated or if the volume is mounted on a new host, the permissions would be wrong again.

Permanent fix

The AI created a custom entrypoint script that runs chown at container startup before dropping privileges and running the application.

Here is the entrypoint script (entrypoint.sh):

#!/bin/sh
set -e

# Ensure the storage directory is writable by the node user
chown -R node:node /app/data/storage

# Drop privileges to node user and run the original entrypoint
exec su -s /bin/sh -c "exec /docker-entrypoint.sh $*" node

The script runs as root because USER node was removed from the end of the Dockerfile. It fixes the permissions, then uses su to switch to node and execute the original entrypoint that ships with the Docmost image.

The AI updated the Dockerfile to copy this script and set it as the entrypoint:

COPY entrypoint.sh /entrypoint-wrapper.sh
RUN chmod +x /entrypoint-wrapper.sh

ENTRYPOINT ["/entrypoint-wrapper.sh"]
CMD ["pnpm", "start"]

It also uncommented the build section in docker-compose.yml so the container builds from the custom Dockerfile instead of pulling the vanilla image.

After rebuilding and restarting, the permissions are now fixed automatically on every container start. I did not have to think about any of this – the AI handled the code changes, I just ran the rebuild command.