Ctrl K
ComunidadGuías…

Dockerfile.production

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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"]