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 current26.6.2release 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:dbanddocuments.
When registration still fails
The browser can flatten different failures into the same bad afternoon. The logs separate them:
| Evidence | What I check |
|---|---|
SQLITE_READONLY or EACCES under /app/app-data | Numeric owner of the host directory versus PAPRA_UID:PAPRA_GID |
Invalid application origin | Browser URL versus APP_BASE_URL; reverse-proxy origin if present |
| Container keeps restarting | The 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?
Thanks for the feedback.