Dockerfile.production
Rama main · réplica de lectura del repositorio original.
# syntax=docker/dockerfile:1-labs@sha256:ad43eec41369d9ef94412c0d1eb3203d12218d01da225afc92a3aeba3a0e7796
# Production Dockerfile for Ghost
# Targets:
# deploy — build the prod dependency closure + a self-contained /home/ghost
# (build-only stage, never shipped)
# report — per-package sizes of that closure, for CI (build-only, not shipped)
# core — server + production deps, no admin (Ghost-Pro base)
# full — core + built admin
# selfhost — full + the runtime contract of the Docker Official Image's `next`
# variant (root entrypoint + gosu, VOLUME, baked config). Published
# as the nightly image; see docker/selfhost/README.md
#
# Build context: the monorepo repo root. The deploy stage runs `pnpm deploy`
# against the workspace to produce a self-contained app dir (source + resolved
# node_modules), so this image no longer depends on the `pnpm pack` output that
# Ghost-CLI uses (scripts/pack.mjs). The `full` stage's admin and embed renderer
# builds are injected into the context by CI under core/built.
#
# Ownership model: application code (node_modules, server source, admin) is owned
# by nobody:nogroup so the runtime user (ghost, uid 1000) can read but not modify
# it; the writable content and log dirs are owned by ghost. Ownership is set at
# COPY time (COPY --chown) rather than via a post-hoc `chown -R`: on overlayfs a
# recursive chown copies every touched file into a new layer, which previously
# duplicated node_modules (~600MB) and the admin build (~80MB) and inflated every
# image pull.
ARG NODE_VERSION=22.23.4
# Set by BuildKit per target platform; declared globally so the selfhost target
# can pick its gosu stage by architecture.
ARG TARGETARCH
# ---- deploy: build closure + self-contained app dir (build-only, not shipped) ----
FROM node:$NODE_VERSION-bookworm-slim AS deploy
WORKDIR /build
RUN corepack enable
# node_modules must live in the image, not link into a pnpm store outside it
ENV pnpm_config_enable_global_virtual_store=false
# Full workspace source is needed so pnpm can resolve the workspace graph for the
# `ghost...` filter. .dockerignore prunes node_modules/build/dist/.git/.nx so the
# build starts from a clean tree and rebuilds outputs in-container.
#
# --exclude core/built: for the `full` target CI injects the admin and embed
# renderer builds under ghost/core/built. Excluding the whole built dir (the
# directory itself is absent on a fresh checkout, so excluding only its children
# would still leave a differing directory entry) keeps this COPY layer's hash
# identical between the core and full builds. That lets `full` reuse `core`'s
# cached deploy/install/build layers instead of re-running the whole closure
# build, and keeps both artifacts out of the deploy output so `core` stays
# admin-free. The server's own core/built output is regenerated in-container by
# the build steps below, so nothing is lost by excluding the host copy.
COPY --exclude=ghost/core/built . .
# Install only ghost and its dependency subgraph (`ghost...`), not the whole
# monorepo (admin, apps, ...). Dev deps are included — tsc etc. are needed to build
# the closure. Scripts are NOT ignored: better-sqlite3's install script runs and
# prebuild-install fetches its prebuilt binary (permitted by allowBuilds in
# pnpm-workspace.yaml). No build-essential/python3 is installed, so a node-gyp
# source-compile fallback fails loudly instead of silently pulling a toolchain into
# a discarded layer. --no-runtime skips downloading the devEngines.runtime Node
# from nodejs.org: the base image already runs NODE_VERSION.
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,id=pnpm-store \
pnpm install --filter "ghost..." --frozen-lockfile --prefer-offline --no-runtime
# Build ghost's production workspace dependency closure (kg-*/adapters; @tryghost/i18n
# has no build script and is skipped) plus ghost's own server output. Driven with
# pnpm recursive filters rather than nx to avoid the NX_NATIVE_COMMAND_RUNNER
# segfault workaround inside the container.
RUN pnpm --filter-prod "ghost^..." -r run build && \
pnpm --filter ghost run build:tsc && \
pnpm --filter ghost run build:assets
# Produce a self-contained deploy dir: ghost's `files` set + a fully resolved,
# production-only node_modules (--prod drops devDeps, keeps optional better-sqlite3).
# Admin is never built here, so core/built/admin and core/built/embed-renderer are
# absent — the core image stays admin-free and the full stage injects both from
# the build context.
#
# inject-workspace-packages: hard-copy the workspace deps (kg-*/adapters/i18n) into
# node_modules instead of linking them back to the workspace. The deploy dir is
# COPYed into the runtime image away from the workspace, so links would dangle.
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,id=pnpm-store \
pnpm --filter=ghost --config.inject-workspace-packages=true deploy --prod /home/ghost
# Strip type-time, doc and native-build-input files the runtime never loads — see
# ghost/scripts/prune.mts for the rules and the exclusions they carry. The COPY
# layer below is extracted single-threaded, once per CI E2E shard, and that cost
# scales with file count: this removes roughly half of them.
RUN node ghost/scripts/prune.mts /home/ghost --profile=image --report=/image-report.json
# Fail the build now if the native module didn't install correctly (missing/broken
# prebuilt binary) rather than at container runtime.
RUN cd /home/ghost && node -e "require('better-sqlite3'); console.log('better-sqlite3 OK')"
# ---- report: per-package sizes of the shipped node_modules (build-only) ----
# CI extracts this with `--target=report --output=type=local` and diffs it against
# the base commit's report, so a dependency that duplicates a large package (a
# peer-forked viem, say) is visible on the PR that adds it. Every layer in `deploy`
# is already cached by the core build, so this target costs an export, not a build.
FROM scratch AS report
COPY --from=deploy /image-report.json /
# ---- Core: server + production deps ----
FROM node:$NODE_VERSION-bookworm-slim AS core
ENV NODE_ENV=production
RUN apt-get update && \
apt-get install -y --no-install-recommends libjemalloc2 fontconfig && \
rm -rf /var/lib/apt/lists/* && \
groupmod -g 1001 node && \
usermod -u 1001 node && \
adduser --disabled-password --gecos "" -u 1000 ghost
WORKDIR /home/ghost
# The deploy stage produced a self-contained app dir (server source + resolved
# node_modules, no admin). Copy it once with its final ownership — no separate
# `chown -R` layer, so node_modules isn't duplicated in the image.
COPY --chown=nobody:nogroup --from=deploy /home/ghost ./
# cp -a preserves the nobody:nogroup ownership set at COPY time, so base_content
# and default need no re-chown; only the writable content/log dirs flip to ghost.
RUN mkdir -p default log && \
cp -a content base_content && \
cp -a content/themes/casper default/casper && \
([ -d content/themes/source ] && cp -a content/themes/source default/source || true) && \
chown ghost:ghost /home/ghost && \
chown nobody:nogroup default && \
chown -R ghost:ghost content log
ARG GHOST_BUILD_VERSION=""
ENV GHOST_BUILD_VERSION=${GHOST_BUILD_VERSION}
USER ghost
ENV LD_PRELOAD=libjemalloc.so.2
EXPOSE 2368
CMD ["node", "index.js"]
# ---- Full: core + admin + embed renderer ----
FROM core AS full
# COPY --chown sets ownership in the copy layer; a post-hoc `chown -R` would
# duplicate the ~80MB admin build into a second layer. Admin and the embed
# renderer are injected into the build context by CI (downloaded from the admin
# build job) under ghost/core/built — the deploy stage excludes that path,
# so this is the only stage that carries them. Local `full` builds must populate
# both paths first (e.g. `pnpm nx run @tryghost/admin:build`).
COPY --chown=nobody:nogroup ghost/core/built/admin core/built/admin
COPY --chown=nobody:nogroup ghost/core/built/embed-renderer core/built/embed-renderer
# ---- gosu: privilege drop for the selfhost entrypoint (build-only) ----
# Static binary, so it is fetched straight into scratch and pinned by checksum
# (from the release's SHA256SUMS) rather than GPG-verified against a keyserver,
# which is one more network dependency for a nightly build to flake on. Only
# the architectures the nightly publishes are listed; another platform fails
# here with a missing `gosu-<arch>` stage. Bump all three lines together.
# https://github.com/tianon/gosu/releases
FROM scratch AS gosu-amd64
ADD --chmod=755 --checksum=sha256:52c8749d0142edd234e9d6bd5237dff2d81e71f43537e2f4f66f75dd4b243dd0 \
https://github.com/tianon/gosu/releases/download/1.19/gosu-amd64 /gosu
FROM scratch AS gosu-arm64
ADD --chmod=755 --checksum=sha256:3a8ef022d82c0bc4a98bcb144e77da714c25fcfa64dccc57f6aba7ae47ff1a44 \
https://github.com/tianon/gosu/releases/download/1.19/gosu-arm64 /gosu
FROM gosu-${TARGETARCH} AS gosu
# ---- Selfhost: full + the Docker Official Image `next` runtime contract ----
# Matches docker-library/ghost's `next` variant (Dockerfile-next.template) in
# everything but how the tree is built, so a nightly can stand in for it:
# - starts as root; docker-entrypoint.sh chowns the content volume, seeds it
# from content.orig, then drops to `ghost` via gosu (`--user` skips that)
# - VOLUME at /home/ghost/content
# - baked config.production.json (listens on ::, logs to stdout)
# docker/selfhost/ is the source of truth for the entrypoint and config; the
# official image copies them rather than the other way round.
FROM full AS selfhost
USER root
COPY --from=gosu /gosu /usr/local/bin/gosu
ENV GHOST_INSTALL=/home/ghost
ENV GHOST_CONTENT=/home/ghost/content
# The official image sets GHOST_VERSION for users and scripts to read; Ghost
# itself doesn't use it. Here it is the exact build (<package version>+<sha> in
# CI), the same value as GHOST_BUILD_VERSION, which core sets from a build arg.
# The RUN below fails a build that omits it or whose version doesn't match
# package.json.
ENV GHOST_VERSION=${GHOST_BUILD_VERSION}
# base_content is the pristine copy of content made in core; the entrypoint
# seeds from content.orig, the name the official image has always used. content
# itself becomes an empty mount point, world-writable so it stays usable
# whatever uid ends up owning a bind mount over it.
RUN set -eux; \
pkgVersion="$(node -p 'require("./package.json").version')"; \
case "$GHOST_VERSION" in \
"$pkgVersion" | "$pkgVersion"+?*) ;; \
*) echo >&2 "error: GHOST_BUILD_VERSION is '$GHOST_VERSION', expected '$pkgVersion' or '$pkgVersion+<build>'"; exit 1 ;; \
esac; \
gosu --version; \
gosu nobody true; \
rm -rf content; \
mv base_content content.orig; \
mkdir content; \
chmod 1777 content; \
ln -s config.production.json config.development.json
COPY --chown=nobody:nogroup docker/selfhost/config.production.json ./
COPY --chmod=755 docker/selfhost/docker-entrypoint.sh /usr/local/bin/
VOLUME $GHOST_CONTENT
# Setting ENTRYPOINT clears the CMD inherited from core, so restate it.
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "index.js"]