Writing Technical guide

Open Source · 5 min read

Papra Was Running. SQLite Still Couldn't Write.

Papra looked healthy on my NAS while registration failed. The browser hid the useful detail: SQLite belonged to the wrong user.

In this article5 sections

I had Papra running on my NAS before I had a working account.

The container started. All 18 database migrations completed. Registration still ended with “Failed to create user.” The useful error, SQLITE_READONLY, existed only in the container logs.

The SQLite file belonged to root. Papra’s process couldn’t write to it.

Looking back My May 18 failure still explains what to check, but I would change the installation order. Papra’s current Docker guide creates the data directories first, mounts all of /app/app-data, and runs the container with the host user’s numeric UID and GID. That prevents the ownership mismatch instead of repairing it later. I checked the guide and current 26.6.2 release on September 3; I haven’t repeated the clean install on the NAS.

Start with the owner

SSH into the NAS and check the account that will own Papra’s files:

id -u
id -g

Keep those two numbers. Then create the persistent directories as that same account:

mkdir -p /volume1/docker/papra/app-data/db
mkdir -p /volume1/docker/papra/app-data/documents
cd /volume1/docker/papra
ls -ldn app-data app-data/db app-data/documents

The last command prints numeric owners. Check them now, while the directories are empty and a mistake is cheap.

One Compose file, one data mount

Papra’s version numbers follow YY.MINOR.PATCH. On September 3, 2026, the release I checked was 26.6.2. I would pin that rootless image for the first boot instead of letting latest change between setup and diagnosis.

Save this as /volume1/docker/papra/docker-compose.yml:

services:
  papra:
    container_name: papra
    image: ${PAPRA_IMAGE}
    restart: unless-stopped
    ports:
      - "127.0.0.1:1221:1221"
    environment:
      AUTH_SECRET: ${AUTH_SECRET}
      APP_BASE_URL: ${APP_BASE_URL}
    volumes:
      - ./app-data:/app/app-data
    user: "${PAPRA_UID}:${PAPRA_GID}"

Put the deployment-specific values in /volume1/docker/papra/.env:

PAPRA_IMAGE=ghcr.io/papra-hq/papra:26.6.2-rootless
PAPRA_UID=1000
PAPRA_GID=1000
APP_BASE_URL=http://localhost:1221
AUTH_SECRET=replace-with-openssl-output

Replace both IDs with the output from id. This example binds Papra to NAS loopback. From your computer, use ssh -L 1221:127.0.0.1:1221 user@example.local, then open http://localhost:1221. For remote access without a tunnel, use a TLS reverse proxy and set APP_BASE_URL to its exact origin. Do not publish the unauthenticated setup port to the internet. Generate the secret locally:

openssl rand -hex 48
chmod 600 /volume1/docker/papra/.env

APP_BASE_URL is part of Papra’s origin check. If I open http://127.0.0.1:1221 while the file says http://example.local:1221, registration can fail with “Invalid application origin.” Papra’s troubleshooting page also documents TRUSTED_ORIGINS for deliberate multi-origin or reverse-proxy setups. I wouldn’t add extra trusted origins to hide a typo.

Do not move an existing library by accident The original version of this post mounted the database and documents separately. The cleaner example above mounts the whole data directory, which matches Papra’s current guide. Before changing a running deployment, inspect its mounts and back up every source path. A new Compose path doesn’t move the old files.

This shows the current container’s host-to-container mappings:

docker inspect papra \
  --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}'

Start it and test the write path

Validate the Compose file without printing the interpolated secret, then start Papra:

cd /volume1/docker/papra
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs --tail=100 papra

My first installation logged 18 migrations. A newer release may log a different count, so I no longer use that number as the health check. I look for a running container, no database error in the logs, and a registration that actually writes the first account.

Test the write path, not just the container. Open the exact APP_BASE_URL, create the account, sign out, and sign back in. Then upload a small document and download it again. That tests both halves of the persistent mount: db and documents.

When registration still fails

The browser can flatten different failures into the same bad afternoon. The logs separate them:

EvidenceWhat I check
SQLITE_READONLY or EACCES under /app/app-dataNumeric owner of the host directory versus PAPRA_UID:PAPRA_GID
Invalid application originBrowser URL versus APP_BASE_URL; reverse-proxy origin if present
Container keeps restartingThe first error before the restart, not the final shutdown line

Check ownership without guessing:

cd /volume1/docker/papra
ls -ldn app-data app-data/db app-data/documents
docker compose run --rm --entrypoint id papra

The numeric IDs should agree. If an earlier rootful start already created the files, stop Papra and repair only the mounted data directory. Replace the placeholders with the IDs you verified:

docker compose stop papra
docker run --rm \
  -v /volume1/docker/papra/app-data:/data \
  alpine:3.22 chown -R <uid>:<gid> /data
ls -ldn app-data app-data/db app-data/documents
docker compose up -d

If the old deployment keeps documents at /volume1/documents, inspect and repair that mount separately. Don’t reach for chmod 777. The problem is ownership, and making every document writable by everyone is a much larger change.

Upgrade with a way back

The original post pulled latest and immediately recreated the container. That is quick, but it erases the answer to “what changed?” I now prefer an explicit image version and a stopped-file backup.

For the one-mount setup above:

cd /volume1/docker/papra
umask 077
mkdir -p backups
docker compose stop papra
tar -czf "backups/app-data-$(date +%F-%H%M%S).tgz" app-data

Read Papra’s changelog, change PAPRA_IMAGE in .env to the version you intend to run, then pull and recreate:

docker compose pull papra
docker compose up -d papra
docker compose logs --tail=100 papra
docker inspect papra --format '{{.Config.Image}}'

Repeat the sign-in and upload/download test. Keep AUTH_SECRET unchanged; replacing it ends existing sessions. Keep the backup until the new version has handled real documents, not merely reached a green container state.

That green badge was the misleading part of my first setup. The container was alive. The write path wasn’t. I hit the same boundary while running Hermes Agent on the same NAS: mounted files care about numeric users, even when the NAS interface does not show them.

Was this useful?

What was missing?

Thanks for the feedback.