diff --git a/Dockerfile b/Dockerfile index 37e9af4..4a41500 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,9 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -RUN addgroup -S app && adduser -S app -G app +# su-exec drops root privileges after the entrypoint fixes bind-mount +# ownership (see docker-entrypoint.sh) — Alpine's lightweight gosu equivalent. +RUN apk add --no-cache su-exec && addgroup -S app && adduser -S app -G app COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static @@ -30,7 +32,11 @@ COPY --from=builder /app/src/db/schema ./src/db/schema COPY docker-entrypoint.sh ./docker-entrypoint.sh RUN mkdir -p /data/uploads && chown -R app:app /data /app -USER app +# Stays root here: docker-compose creates bind-mounted host volumes (uploads, +# tus staging) owned by root when they don't already exist, which "app" can't +# write to — the classic Docker bind-mount permissions gotcha. The entrypoint +# fixes ownership as root, then drops to "app" via su-exec before starting +# the actual process, so the app itself never runs as root. EXPOSE 3000 ENV PORT=3000 HOSTNAME=0.0.0.0 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index d118917..16a26a7 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,8 +1,47 @@ #!/bin/sh set -e +# Bind-mounted volumes (uploads, tus staging) are created by Docker as +# root-owned directories on the host when they don't already exist — Docker +# Compose creates missing bind-mount source paths as root regardless of the +# image's USER. The non-root "app" user this image runs as can't write to a +# root-owned directory, which breaks every upload with no obvious cause. +# +# Fix ownership here, while still root, then drop to "app" via su-exec before +# starting the real process — the same pattern official images like +# postgres/mysql use. Only a first-start-after-a-fresh-mount needs the +# (potentially slow, recursive) chown: once "app" owns the directory, every +# file it creates afterwards is already correct, so later starts just check +# the top level and skip the walk. +if [ "$(id -u)" = "0" ]; then + app_uid=$(id -u app) + for dir in "${UPLOAD_DIR:-/data/uploads}" "${TUS_DIR:-/data/tus-incoming}"; do + if [ -d "$dir" ] && [ "$(stat -c %u "$dir" 2>/dev/null)" != "$app_uid" ]; then + echo "Fixing ownership of $dir (mounted as root by Docker)..." + chown -R app:app "$dir" || echo "warn: could not chown $dir — uploads may fail if it stays root-owned" + fi + done + RUN_AS="su-exec app" +else + # Already running as a non-root user (e.g. a custom `user:` override in + # compose) — nothing to fix, nothing to drop to. + RUN_AS="" +fi + echo "Running database migrations..." -node node_modules/drizzle-kit/bin.cjs migrate +$RUN_AS node node_modules/drizzle-kit/bin.cjs migrate + +if [ "$#" -gt 0 ]; then + # A custom command was given (e.g. docker-compose `command: ["npm", "run", + # "worker"]`). This standalone, prebuilt image is web-server-only — the + # dedicated worker needs tsx and the raw TypeScript sources, which only + # exist in a full source checkout / custom-built image. Exec it anyway and + # let it fail loudly (missing package.json / tsx) instead of silently + # falling through to starting the web server on what the operator intended + # to be a separate worker container. + echo "Starting: $*" + exec $RUN_AS "$@" +fi echo "Starting StudyHelper..." -exec node server.js +exec $RUN_AS node server.js diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 0f06756..bca6eed 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -96,6 +96,41 @@ and links to the release. Installing it is a manual **Do not lose `ENCRYPTION_KEY`** — encrypted settings (AI keys, SMTP, OIDC secrets) become unreadable without it. +## Troubleshooting + +### Admin shows "file storage is not writable" / uploads fail + +Docker Compose creates a bind-mounted host directory (`./data/uploads`) owned +by **root** the first time it doesn't already exist, but the app runs as a +non-root user inside the container — so it can't write to it. The image's +entrypoint fixes this automatically on container start (it corrects +ownership before starting the app). If you still see the warning after a +fresh `docker compose pull && docker compose up -d`, fix it once by hand: + +```bash +docker compose exec -u root \ + sh -c 'chown -R app:app /data/uploads /data/tus-incoming' +``` + +(`` is whatever you named the app service in your compose +file — `app` in the example above.) If the warning persists after upgrading, +the volume is likely on a filesystem that ignores container-side chown +entirely (e.g. certain NFS exports with root-squash) — move `DATA_DIR` to a +regular local/host-managed disk. + +### The dedicated `worker` container doesn't seem to process anything + +The optional `worker` service (commented out by default) runs `npm run +worker`, which needs `tsx` and the raw TypeScript sources — the prebuilt +`ghcr.io/veniplex/study-helper` image is a **standalone, web-server-only** +build and doesn't include either — pointing that service at the prebuilt +image fails fast with a clear startup error instead of silently starting a +second (unreachable) copy of the web server. Unless you build a +custom image from a full source checkout, don't run the `worker` service — +the web tier already processes background jobs in-process by default, which +is sufficient for most deployments. Only reach for `WORKERS_IN_PROCESS=false` ++ a dedicated worker if you're building your own image. + ## Object storage (S3) By default uploaded files are stored on disk (`STORAGE_DRIVER=local`, under