# 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-` 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 (+ 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+'"; 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"]