Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 41 additions & 2 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app-service-name> \
sh -c 'chown -R app:app /data/uploads /data/tus-incoming'
```

(`<app-service-name>` 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
Expand Down
Loading