Files
ihasmail/Dockerfile
T
jcoffey-dev f72c67864e Let the container run with nothing writable
The server writes to one path and no other: SESSION_FILE, from sessions.ts.
Everything else it touches on disk it only reads. So a container with a
read-only root filesystem already works -- except that `VOLUME ["/data"]`
quietly undid it. Docker acts on that directive: a container started without
`-v` gets an anonymous volume mounted there anyway, writable even under
`--read-only`. It persisted nothing across a redeploy, since each new container
got a fresh empty volume, and it left an orphan behind every time one was
replaced. Deployments that want the sessions to survive already say so
themselves -- docker-compose.yml and deploy.example.sh both mount a named
volume -- so removing the line changes nothing for them.

IMMUTABLE=1 asserts that this is how the instance is running. It is checked
rather than believed: the server refuses to start if SESSION_FILE is still set,
or if the filesystem it is installed on turns out to be writable. Left
unchecked the misconfiguration is silent, because persisting sessions is
best-effort -- a read-only /data costs one warning at the first sign-in and
nothing more until the instance is replaced and everyone is signed out.

SessionBackend names what the rest of the server asks of a session store, and
`sessions` in app.ts is typed as it. Nothing changes today; SessionStore is
still the only implementation. It is there so the OAuth work is written against
the interface rather than the class, and so the interface can record which of
its methods a stateless backend could satisfy alone: create, resolve, reseal
and destroy each touch one session, while listForUser and destroyAllForUser
have to reach sessions other than the caller's. The second of those carries the
guarantee that changing a password invalidates the sessions still holding the
old one, which is why it needs a registry -- Stalwart's token registry, once
sign-in goes through OAuth.
2026-08-27 21:48:23 -07:00

52 lines
2.3 KiB
Docker

# ---- build stage ----
FROM node:22-alpine AS build
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
# context on purpose, and git is not installed either. `node scripts/version.mjs`
# in a checkout prints the right answer; ihasmail-deploy.sh passes it through.
# Left empty, the build falls back to the base version from package.json.
ARG IHASMAIL_VERSION=""
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json server/
COPY web/package.json web/
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
# ---- runtime stage ----
FROM node:22-alpine AS runtime
# Re-declared: an ARG does not cross stages.
ARG IHASMAIL_VERSION=""
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080 \
STATIC_DIR=/app/web/dist \
SESSION_FILE=/data/sessions.json \
IHASMAIL_VERSION=$IHASMAIL_VERSION
WORKDIR /app
COPY package.json ./
COPY server/package.json server/
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
# set it never looks further; without it, it falls back to package.json rather
# than failing, since there is no git in here to ask.
COPY scripts/ ./scripts/
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
RUN mkdir -p /data && chown -R node:node /data /app
USER node
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
# goes, but Docker acts on it: a container started without `-v` gets an
# anonymous volume mounted there anyway, and that mount stays writable even
# under `--read-only`. So the directive quietly put a writable hole in a
# container meant to be immutable, and left an orphaned volume behind every
# time one was replaced -- while never persisting anything across a redeploy,
# since each new container got a fresh empty volume of its own. Deployments
# that want the sessions to survive say so themselves: docker-compose.yml and
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
CMD ["node", "server/dist/index.js"]