diff --git a/.env.example b/.env.example index 4e1ea78..0c20802 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,18 @@ APP_SECRET=change-me HOST=0.0.0.0 PORT=8080 +# Serve the app from a subpath instead of the domain root, for a reverse proxy +# that maps https://example.com/mail/ here. Leave it unset for the root, which +# is what every deployment gets unless it asks otherwise. "/mail", "mail" and +# "/mail/" all mean the same thing. +# +# The prefix must reach ihasmail intact -- do not strip it in the proxy -- and +# it has to be set for the *build* as well as the run: the web bundle writes +# its own asset URLs, so a build that does not know the prefix produces an app +# that cannot load itself under one. With Docker that means +# `--build-arg BASE_PATH=/mail` alongside `-e BASE_PATH=/mail`. +# BASE_PATH=/mail + # Set to "1" when running behind a TLS-terminating reverse proxy (trusts # X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev. TRUST_PROXY=1 diff --git a/Dockerfile b/Dockerfile index 952700c..e0c8fab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,15 @@ FROM node:22-alpine AS build # Left empty, the build falls back to the base version from package.json. ARG IHASMAIL_VERSION="" ENV IHASMAIL_VERSION=$IHASMAIL_VERSION +# The subpath the app will be served from, e.g. /mail. Empty -- the default -- +# is the domain root and is what every deployment gets unless it asks +# otherwise. Unlike the rest of ihasmail's configuration this cannot wait for +# the process to start: the web build writes its own asset URLs into +# index.html, so a build that does not know the prefix produces a shell that +# cannot load itself under one. It is therefore a build argument here and an +# environment variable in the runtime stage, from the same value. +ARG BASE_PATH="" +ENV BASE_PATH=$BASE_PATH WORKDIR /app COPY package.json package-lock.json* ./ COPY server/package.json server/ @@ -19,12 +28,14 @@ RUN npm run build FROM node:22-alpine AS runtime # Re-declared: an ARG does not cross stages. ARG IHASMAIL_VERSION="" +ARG BASE_PATH="" 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 + IHASMAIL_VERSION=$IHASMAIL_VERSION \ + BASE_PATH=$BASE_PATH WORKDIR /app COPY package.json ./ COPY server/package.json server/ @@ -47,5 +58,14 @@ USER node # 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 +# Shell form, so $BASE_PATH is expanded by the container rather than baked in +# empty at build time: the health endpoint moves with the mount. +# +# The two substitutions repeat, in sh, what scripts/basePath.mjs does in +# JavaScript -- drop a trailing slash, add a leading one -- because this runs +# before there is a Node process to ask. It is worth the duplication: an +# operator who writes BASE_PATH=mail/ gets a working server, and without this +# a healthcheck that says the working server is unhealthy and has Docker +# restart it forever. +HEALTHCHECK --interval=30s --timeout=5s CMD BP="${BASE_PATH%/}"; case "$BP" in ""|/*) ;; *) BP="/$BP";; esac; wget -qO- "http://127.0.0.1:8080$BP/api/health" || exit 1 CMD ["node", "server/dist/index.js"] diff --git a/FEATURES.md b/FEATURES.md index 6191795..09c9c81 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1033,6 +1033,7 @@ wizard, because either would be state. | `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` | | `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it | | `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address | +| `BASE_PATH` | — (the domain root) | Subpath to serve from, e.g. `/mail`. Must be set for the **build** as well as the run — see below | | `TRUST_PROXY` | `1` | Believe `X-Forwarded-*` | | `TRUSTED_PROXIES` | loopback + private ranges | Which peers to believe | | `SECURE_COOKIES` | `auto` | `Secure` when the request arrived over HTTPS; `1`/`0` to force | @@ -1052,6 +1053,41 @@ Full documentation, including TLS and reverse proxies: [Configuring](https://docs.ihasmail.org/configure/). `Caddyfile.example` and `nginx.example.conf` are in the repository. +### Serving from a subpath + +`BASE_PATH` mounts the whole app under a prefix, for a host that is not +ihasmail's alone: + +```bash +docker build --build-arg BASE_PATH=/mail -t ihasmail . +docker run -e BASE_PATH=/mail ... ihasmail +``` + +`/mail`, `mail` and `/mail/` all mean the same mount; unset means the domain +root, which is exactly what it has always been. Everything moves together — +`/mail/api/health`, every deep link, the icons, the manifest, the service +worker's scope and the session cookie's `Path`. + +Two things are worth knowing before you reach for it. + +**The prefix must arrive intact.** Point the proxy at ihasmail without +stripping it: `proxy_pass http://127.0.0.1:8080;` with no trailing slash in +nginx, `reverse_proxy` without a `uri strip_prefix` in Caddy. A proxy that +strips the prefix is talking to an app at the root, and should be paired with +no `BASE_PATH` at all. + +**It is baked in at build time, not only at run time.** This is the one setting +that cannot wait for the process to start: the web bundle writes its own +`