From effe7e3e723562af5a2b70e88c51fe106b69adec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:26:39 +0000 Subject: [PATCH] fix(docker): make bind-mounted data folders writable on startup The image switched to the node user (uid 1000) at build time, so a host folder mounted at /data kept its host ownership and SQLite failed with SQLITE_CANTOPEN in a restart loop. Named and anonymous volumes worked only because Docker copies the image's /data ownership into them. The container now starts as root, hands DATA_DIR and the database folder to node (only entries not already owned by it), and execs the app via setpriv as node. Containers started with an explicit `user:` skip this. Adds a CI step that runs the image with a host-owned bind mount. Fixes #93 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016MHECwBhBpM248EFSBi4L5 --- .github/workflows/ci.yml | 16 ++++++++++++++++ Dockerfile | 6 ++++-- crumbs-entrypoint.sh | 20 ++++++++++++++++++++ docs/DEPLOYMENT.md | 4 ++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100755 crumbs-entrypoint.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eca1040..e0522e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,5 +125,21 @@ jobs: push: false provenance: false tags: crumbs:ci + load: true cache-from: type=gha,scope=ci-docker cache-to: type=gha,scope=ci-docker,mode=max + + # Regression for #93: a host folder bind-mounted at /data is owned by the + # host user (the runner, uid 1001), not the container's node user (1000) + - name: Start with a host-owned bind mount + run: | + mkdir -p "$RUNNER_TEMP/crumbs-data" + docker run -d --name crumbs -p 3000:3000 -v "$RUNNER_TEMP/crumbs-data:/data" crumbs:ci + for i in $(seq 1 30); do + if curl -fsS -o /dev/null http://localhost:3000/login; then + test -f "$RUNNER_TEMP/crumbs-data/crumbs.db" && exit 0 + fi + sleep 1 + done + docker logs crumbs + exit 1 diff --git a/Dockerfile b/Dockerfile index 00d629a..a2ea7b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,10 @@ COPY --from=build /app/package.json ./ # Create data directory RUN mkdir -p /data && chown -R node:node /data -# Use non-root user -USER node +# Runs as root only long enough to hand the data folder (often a host bind +# mount) to the node user, then drops privileges — see crumbs-entrypoint.sh +COPY crumbs-entrypoint.sh /usr/local/bin/crumbs-entrypoint.sh +ENTRYPOINT ["crumbs-entrypoint.sh"] # Configuration ENV NODE_ENV=production diff --git a/crumbs-entrypoint.sh b/crumbs-entrypoint.sh new file mode 100755 index 0000000..2803636 --- /dev/null +++ b/crumbs-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Starts as root so a bind-mounted data folder owned by the host user can be +# handed to the unprivileged `node` user, then drops privileges before running +# the app. Without this, a host folder mounted at /data is not writable by +# uid 1000 and SQLite fails with SQLITE_CANTOPEN. +set -e + +if [ "$(id -u)" = "0" ]; then + for dir in "${DATA_DIR:-/data}" "$(dirname "${DATABASE_URL:-/data/crumbs.db}")"; do + mkdir -p "$dir" + # Only touch entries not already owned by node, so restarts on a large + # attachment folder stay fast. + find "$dir" \! -user node -exec chown node:node {} + || + echo "warning: could not change ownership of $dir; it must be writable by uid 1000" >&2 + done + exec setpriv --reuid=node --regid=node --init-groups "$@" +fi + +# Started with an explicit non-root user (e.g. `user:` in compose): run as-is. +exec "$@" diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 83b973d..7665b15 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -26,6 +26,10 @@ services: restart: unless-stopped ``` +The app runs as the image's `node` user (uid 1000). On startup the container hands ownership of a +bind-mounted data folder to uid 1000, so a folder created by any host user works as-is. If you set +`user:` in compose, that step is skipped and the folder must already be writable by that user. + ### Reverse Proxy (nginx) ```nginx