Skip to content
Open
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
30 changes: 25 additions & 5 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"]
48 changes: 39 additions & 9 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
echo "starting Next.js"
exec "$@"
Loading