diff --git a/docker/Dockerfile b/docker/Dockerfile index 253a1a67..4febc803 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,12 @@ FROM node:20-slim +# tini runs as PID 1 and forwards signals to node (which doesn't install its +# own SIGTERM handler, and as PID 1 would otherwise ignore it) — so +# `docker stop` terminates in ~1s instead of waiting out the 10s kill grace. +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /usr/app ENV NODE_ENV=production @@ -13,18 +20,31 @@ ENV PORT=3000 # install here. The output is traced against the runner (Ubuntu/glibc), so this # runtime image must also be glibc (node:20-slim, NOT alpine/musl) or the traced # native binaries won't load. -COPY .next/standalone ./ +# +# Files are chowned to the non-root `node` user because entrypoint.sh rewrites +# the DocSearch placeholders in .next with `sed -i` at runtime — that needs +# write access under the runtime user. +COPY --chown=node:node .next/standalone ./ # standalone does not include static assets or the public dir — copy them in. -COPY .next/static ./.next/static -COPY public ./public +COPY --chown=node:node .next/static ./.next/static +COPY --chown=node:node public ./public -COPY docker/entrypoint.sh ./entrypoint.sh +COPY --chown=node:node docker/entrypoint.sh ./entrypoint.sh RUN chmod +x ./entrypoint.sh +# Run as the base image's built-in non-root user (UID 1000). Port 3000 is +# unprivileged, so no extra capability is needed. +USER node + EXPOSE 3000 +# Surfaces crash-loops and dead servers in `docker ps` / compose --wait / +# Watchtower instead of them sitting silently "Up". +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + # entrypoint.sh substitutes the APP_NEXT_PUBLIC_DOCSEARCH_* placeholders baked # into .next with real values from the container env, then execs the CMD. -ENTRYPOINT ["/usr/app/entrypoint.sh"] +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/app/entrypoint.sh"] CMD ["node", "server.js"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bcb87030..d50ce102 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,17 +1,47 @@ #!/bin/sh -# this script will check for the following NEXT_* environment variables passed via Docker environment (-e) and apply them -# to the Nextjs. -# The properties that will be replaced and have to start with APP_ prefix in the .env file +# Substitutes the APP_NEXT_PUBLIC_DOCSEARCH_* placeholders baked into the +# client bundle at build time (from the committed .env) with the real values +# passed via the container environment, then starts the server. +# +# NEXT_PUBLIC_* values are compiled into the client bundle, so this rewrite is +# what lets one image serve any environment's DocSearch credentials. + +set -eu -set -ex NEXT_PUBLIC_DOCSEARCH_APP_ID=${NEXT_PUBLIC_DOCSEARCH_APP_ID:-"none"} NEXT_PUBLIC_DOCSEARCH_API_KEY=${NEXT_PUBLIC_DOCSEARCH_API_KEY:-"none"} NEXT_PUBLIC_DOCSEARCH_INDEX_NAME=${NEXT_PUBLIC_DOCSEARCH_INDEX_NAME:-"none"} -find /usr/app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#APP_NEXT_PUBLIC_DOCSEARCH_APP_ID#${NEXT_PUBLIC_DOCSEARCH_APP_ID}#g" -find /usr/app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#APP_NEXT_PUBLIC_DOCSEARCH_API_KEY#${NEXT_PUBLIC_DOCSEARCH_API_KEY}#g" -find /usr/app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#APP_NEXT_PUBLIC_DOCSEARCH_INDEX_NAME#${NEXT_PUBLIC_DOCSEARCH_INDEX_NAME}#g" +# Escape the characters that are special in a sed replacement (\ and &) and +# our s### delimiter (#), so values containing them substitute literally. +# CR/LF are stripped first: a one-line s### command cannot carry a raw +# newline, and no legitimate DocSearch token contains one. +escape() { + printf '%s' "$1" | tr -d '\r\n' | sed -e 's/[\\&#]/\\&/g' +} + +# Rewrite only the files that still contain the placeholder — after the first +# boot substituted everything, restarts touch nothing. grep exiting 1 on zero +# matches is fine: xargs -r then runs nothing and the pipeline succeeds. +substitute() { + grep -rlZ "$1" /usr/app/.next | xargs -0 -r sed -i "s#$1#$2#g" +} + +# Each substitution runs independently: one failing value must not stop the +# remaining placeholders from being applied. +ok=1 +substitute APP_NEXT_PUBLIC_DOCSEARCH_APP_ID "$(escape "$NEXT_PUBLIC_DOCSEARCH_APP_ID")" || ok=0 +substitute APP_NEXT_PUBLIC_DOCSEARCH_API_KEY "$(escape "$NEXT_PUBLIC_DOCSEARCH_API_KEY")" || ok=0 +substitute APP_NEXT_PUBLIC_DOCSEARCH_INDEX_NAME "$(escape "$NEXT_PUBLIC_DOCSEARCH_INDEX_NAME")" || ok=0 + +if [ "$ok" = 1 ]; then + echo "DocSearch configuration applied" +else + # Serve the docs even if search wiring failed — a docs site with broken + # search beats a crash-looping container. The warning makes it visible. + echo "WARNING: DocSearch placeholder substitution failed; search may be broken" >&2 +fi -echo "starting Nextjs" -exec "$@" \ No newline at end of file +echo "starting Next.js" +exec "$@"