The Dockerfile creates /data/mirror and /data/state with the right owner, and the comment above it says exactly why: Docker initialises a new named volume from the image directory, so a mount point the image does not have lands root-owned and the unprivileged user gets EACCES on every write. /data/notes was added to docker-compose.yml without being added here, so every save on a fresh deployment would have failed that way — verified both directions before fixing it. docs/DEPLOY-NOTES.md is the runbook for putting this on a server that is already running: publish, decide where the notes live *before* anything writes one, set WEB_PASSWORD, verify, migrate, index. Plus rollback, which is uneventful — no migration, and the old image simply ignores the new settings and leaves the notes volume alone. PI.md's backup table needed the bigger change. Everything else this server stores is a copy of something upstream and a crawl rebuilds it; the notes are not, and nothing can. They are now the one entry in that table marked irreplaceable, and the EACCES row says to fix the volume's ownership rather than delete it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
55 lines
2.0 KiB
Docker
55 lines
2.0 KiB
Docker
# syntax=docker/dockerfile:1
|
|
|
|
# Build stage: full dependency tree, compile TypeScript to dist/.
|
|
FROM node:22-alpine AS build
|
|
WORKDIR /app
|
|
COPY package.json package-lock.json ./
|
|
RUN npm ci
|
|
COPY tsconfig.json ./
|
|
COPY src ./src
|
|
# tsc emits only .js, so the .sql migrations are copied by this step.
|
|
COPY scripts/copy-assets.mjs ./scripts/copy-assets.mjs
|
|
RUN npm run build
|
|
|
|
# Prune to runtime dependencies only, in its own stage so the build tree
|
|
# (typescript, @types) never reaches the final image.
|
|
FROM node:22-alpine AS deps
|
|
WORKDIR /app
|
|
COPY package.json package-lock.json ./
|
|
RUN npm ci --omit=dev && npm cache clean --force
|
|
|
|
FROM node:22-alpine AS runtime
|
|
WORKDIR /app
|
|
ENV NODE_ENV=production
|
|
|
|
# Every extractor is pure JavaScript, so the runtime image needs no build
|
|
# toolchain — just a signal-forwarding init so SIGTERM reaches node.
|
|
RUN apk add --no-cache tini
|
|
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY --from=build /app/dist ./dist
|
|
COPY package.json ./
|
|
|
|
# The mirror, the state directory (a replaced session token) and the notes are
|
|
# the only writable paths. Creating them in the image with the right owner
|
|
# matters: Docker initialises a new named volume from the image directory,
|
|
# including its ownership, so without this the volume lands root-owned and the
|
|
# unprivileged user gets EACCES on every write — with the failure recorded
|
|
# rather than crashing, which makes it easy to miss. The state directory holds a
|
|
# credential and the notes are personal, so only their owner may enter either.
|
|
RUN mkdir -p /data/mirror /data/state /data/notes \
|
|
&& chown -R node:node /data \
|
|
&& chmod 700 /data/state /data/notes
|
|
|
|
# node:alpine ships an unprivileged `node` user.
|
|
USER node
|
|
|
|
EXPOSE 8080
|
|
ENV PORT=8080 BIND_HOST=0.0.0.0
|
|
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
|
|
ENTRYPOINT ["/sbin/tini", "--"]
|
|
CMD ["node", "dist/bin/http.js"]
|