Dockerfile
Rama main · réplica de lectura del repositorio original.
# syntax=docker/dockerfile:1.7
# GoodWorkshop -- single self-contained image for on-prem deployment.
#
# Build: docker build -t goodworkshop .
# Run: docker run -p 3000:3000 goodworkshop
#
# No secret ever enters an ARG or a layer. Everything the app needs at runtime
# (DATABASE_URL, GW_APP_URL, SMTP_URL, ...) arrives as an environment variable
# when the container starts.
#
# There is no AUTH_SECRET, despite what this comment used to say. Sessions are
# rows in Postgres with a per-session random secret, not signed tokens, so
# there is no signing key -- and naming one told operators they had taken a
# precaution that does not exist.
ARG NODE_VERSION=22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914
# --- deps -------------------------------------------------------------------
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
COPY scripts/licenses.mjs ./scripts/licenses.mjs
# The notice file is written HERE, in the same RUN as the install, and for one
# reason: `pnpm licenses list` reads the pnpm store, and the store only exists
# inside this cache mount. The builder stage receives node_modules but no
# store, so the same command fails there with
# ERR_PNPM_MISSING_PACKAGE_INDEX_FILE -- which is exactly how it failed once.
#
# What it produces is the copyright lines and licence texts of everything that
# ships. MIT, BSD and Apache all require those to travel WITH the distributed
# software, and a list in the source repository does not discharge that for
# somebody who only ever receives the image.
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
PNPM_HOME=/pnpm pnpm install --frozen-lockfile && \
PNPM_HOME=/pnpm node scripts/licenses.mjs --notices THIRD-PARTY-LICENSES.txt
# --- builder ----------------------------------------------------------------
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
RUN corepack enable
ENV NEXT_TELEMETRY_DISABLED=1
# community | cloud. Only this stage sees it: the build picks the edition's code
# and records the choice in dist/edition.json, which is what migrate and
# provision read at runtime. The runner stage never gets the variable, so a
# container cannot be switched to another edition by its environment.
ARG GW_EDITION=community
# Where the cloud build finds its accounting and payment adapters, relative to
# this build context. Only the private cloud build passes it; left empty, the
# build uses the adapters that refuse every invoice and every charge.
ARG GW_BILLING_ADAPTERS=
# Where the cloud build finds the Discover catalogue. Left empty, the build gets
# a catalogue with nothing in it -- an empty library, not a broken one.
ARG GW_CATALOG=
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN GW_EDITION=${GW_EDITION} GW_BILLING_ADAPTERS=${GW_BILLING_ADAPTERS} \
GW_CATALOG=${GW_CATALOG} pnpm build && \
if [ "${GW_EDITION}" != "cloud" ]; then rm -rf drizzle-cloud && mkdir drizzle-cloud; fi
# --- runner -----------------------------------------------------------------
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
# The Node base image is a development image: it ships npm, npx, corepack and
# Yarn, and with them a few hundred packages this server never loads -- tar,
# pacote, sigstore and friends, each with advisories of their own that an
# operator's scanner then reports against GoodWorkshop. Nothing here runs a
# package manager at runtime, so they go. `apk upgrade` because the base image
# digest is pinned: without it, an OpenSSL fix published after the pin would
# only arrive when somebody moves the pin, and the image scan in CI would fail
# on exactly that in the meantime.
RUN apk upgrade --no-cache && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v*
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
# Baked in at build time so /api/health can report which image is running --
# that is how a rolling deploy notices it is serving the wrong version.
#
# Twice, on purpose. compose passes GW_VERSION from the .env into the container,
# which replaces this value with the image TAG -- right for /api/health, and
# the reason the footer once read "main". GW_BUILD is the same value under a
# name compose leaves alone, so the footer can still say which build it is.
ARG GW_VERSION=dev
ENV GW_VERSION=${GW_VERSION} \
GW_BUILD=${GW_VERSION}
# `output: 'standalone'` traces exactly the files the server needs, so the
# runtime layer carries no package manager and no dev dependencies.
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=deps --chown=node:node /app/THIRD-PARTY-LICENSES.txt ./THIRD-PARTY-LICENSES.txt
# Apache 2.0 section 4 asks that whoever receives the software also receives a
# copy of the licence (a) and the NOTICE file (d). An image pulled onto
# somebody's server is exactly that, whether or not they ever see this repo.
COPY --chown=node:node LICENSE ./LICENSE
COPY --chown=node:node NOTICE ./NOTICE
# The operational half of the image: migrations, provisioning and the CLI that
# gets an operator in when there is neither HTTPS nor SMTP. Without these the
# container serves pages but cannot set up or repair its own database.
COPY --from=builder --chown=node:node /app/scripts ./scripts
COPY --from=builder --chown=node:node /app/drizzle ./drizzle
# Empty in a community image; see the builder stage.
COPY --from=builder --chown=node:node /app/drizzle-cloud ./drizzle-cloud
COPY --from=builder --chown=node:node /app/src/domain/moduleType/builtins.json ./src/domain/moduleType/builtins.json
# `output: standalone` traces only what the SERVER imports. The scripts pull in
# the migrator, which no route touches, so it would be traced away.
COPY --from=deps --chown=node:node /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
# The collaboration server. Bundled separately because nothing a route imports
# reaches it, so Next's output tracing would leave it out entirely -- and it
# runs in THIS image rather than a second one, so an on-prem install stays
# "one image plus Postgres".
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=deps --chown=node:node /app/node_modules/ws ./node_modules/ws
USER node
EXPOSE 3000 3001
# Fail-closed: a non-2xx or an unreachable server marks the container unhealthy
# instead of letting a load balancer keep sending traffic to it.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# Both processes, one container. See scripts/start-container.mjs for why there
# is no supervisor.
CMD ["node", "scripts/start-container.mjs"]