Skip to content

Update 6 to 6.61.0 - #22147

Merged
yosifkit merged 3 commits into
docker-library:masterfrom
Ghost-Slimer:update-ghost
Aug 28, 2026
Merged

Update 6 to 6.61.0#22147
yosifkit merged 3 commits into
docker-library:masterfrom
Ghost-Slimer:update-ghost

Conversation

@Ghost-Slimer

@Ghost-Slimer Ghost-Slimer commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Update 6 to 6.61.0

Generated from TryGhost/docker-library-ghost@820b574.

closes #22081

@github-actions

This comment has been minimized.

closes docker-library#22081
- added new 6-next tagged images
@github-actions

This comment has been minimized.

@yosifkit

yosifkit commented Aug 27, 2026

Copy link
Copy Markdown
Member

I usually reject PRs that attempt to use a multi-stage build since they are often just avoiding clean up steps. But this is a little more nuanced than that. The differences that I see compared to just building FROM node:...:

  • renaming the node user to ghost
  • not having to clean up build-time packages or caches
  • not shipping yarn v1 in the image

While I would reject if it was just doing the first two since a multi-stage build just adds complication and those can be more simply accomplished without it, I am enticed by the removal of yarn. That is the default in the newest node images, but if it is reasonable to also remove npm (npm uninstall -g npm in the build stage) I could be convinced of the multi-stage build.

@acburdine

Copy link
Copy Markdown
Contributor

@yosifkit The original approach just copied the node binary from the base image, the reason I copied npm as well is that Ghost's adapters are often shipped as npm packages, and it's easiest to install them by fetching the tarball from npm - npm pack <pkg>@version has that built in.

@acburdine

Copy link
Copy Markdown
Contributor

also see #22081 (comment) - my understanding of this bit of @tianon's comment:

For removing the user, you might consider some surgical COPY --from=node

was to use multi-stage, though I might have misunderstood what was meant there

@tianon

tianon commented Aug 27, 2026

Copy link
Copy Markdown
Member

I think my conceptual was maybe more surgical than just copying everything - if you're just copying everything, renaming the user instead is pretty lightweight (and the other tool cleanup code already exists 😅).

@acburdine

Copy link
Copy Markdown
Contributor

Made TryGhost/docker-library-ghost#482 switching it back to a single-stage image. Adds about ~17mb of overhead but that's probably fine, esp since this next variant is still several hundred mb smaller than the current one overall.

@yosifkit

Copy link
Copy Markdown
Member

I think TryGhost/docker-library-ghost#482 looks good.

@github-actions

Copy link
Copy Markdown
Diff for a96de87:
diff --git a/_bashbrew-cat b/_bashbrew-cat
index ff806f5..2165c8b 100644
--- a/_bashbrew-cat
+++ b/_bashbrew-cat
@@ -1,11 +1,21 @@
 Maintainers: Austin Burdine <austin@ghost.org> (@acburdine)
 GitRepo: https://github.com/TryGhost/docker-library-ghost.git
-GitCommit: 4a9626af1d9f6a8f903ab6246fda29e56dcf106f
+GitCommit: 653aed98404d4e3428aa09a77347a2d3d7d775f2
 
-Tags: 6.60.0-alpine3.23, 6.60.0-alpine, 6.60-alpine3.23, 6.60-alpine, 6-alpine3.23, 6-alpine, alpine3.23, alpine
+Tags: 6.61.0-alpine3.23, 6.61.0-alpine, 6.61-alpine3.23, 6.61-alpine, 6-alpine3.23, 6-alpine, alpine3.23, alpine
 Architectures: amd64, arm64v8
 Directory: 6/alpine3.23
 
-Tags: 6.60.0-bookworm, 6.60.0, 6.60-bookworm, 6.60, 6-bookworm, 6, bookworm, latest
+Tags: 6.61.0-bookworm, 6.61.0, 6.61-bookworm, 6.61, 6-bookworm, 6, bookworm, latest
 Architectures: amd64, arm32v7, arm64v8
 Directory: 6/bookworm
+
+Tags: 6.61.0-next-alpine3.23, 6.61.0-next-alpine, 6.61-next-alpine3.23, 6.61-next-alpine, 6-next-alpine3.23, 6-next-alpine, next-alpine3.23, next-alpine
+Architectures: amd64, arm64v8
+Directory: 6-next/alpine3.23
+Builder: buildkit
+
+Tags: 6.61.0-next-bookworm, 6.61.0-next, 6.61-next-bookworm, 6.61-next, 6-next-bookworm, 6-next, next-bookworm, next
+Architectures: amd64, arm32v7, arm64v8
+Directory: 6-next/bookworm
+Builder: buildkit
diff --git a/_bashbrew-list b/_bashbrew-list
index 55069e3..539886a 100644
--- a/_bashbrew-list
+++ b/_bashbrew-list
@@ -2,15 +2,31 @@ ghost:6
 ghost:6-alpine
 ghost:6-alpine3.23
 ghost:6-bookworm
-ghost:6.60
-ghost:6.60-alpine
-ghost:6.60-alpine3.23
-ghost:6.60-bookworm
-ghost:6.60.0
-ghost:6.60.0-alpine
-ghost:6.60.0-alpine3.23
-ghost:6.60.0-bookworm
+ghost:6-next
+ghost:6-next-alpine
+ghost:6-next-alpine3.23
+ghost:6-next-bookworm
+ghost:6.61
+ghost:6.61-alpine
+ghost:6.61-alpine3.23
+ghost:6.61-bookworm
+ghost:6.61-next
+ghost:6.61-next-alpine
+ghost:6.61-next-alpine3.23
+ghost:6.61-next-bookworm
+ghost:6.61.0
+ghost:6.61.0-alpine
+ghost:6.61.0-alpine3.23
+ghost:6.61.0-bookworm
+ghost:6.61.0-next
+ghost:6.61.0-next-alpine
+ghost:6.61.0-next-alpine3.23
+ghost:6.61.0-next-bookworm
 ghost:alpine
 ghost:alpine3.23
 ghost:bookworm
 ghost:latest
+ghost:next
+ghost:next-alpine
+ghost:next-alpine3.23
+ghost:next-bookworm
diff --git a/_bashbrew-list-build-order b/_bashbrew-list-build-order
index 31c0ef3..6fdef7b 100644
--- a/_bashbrew-list-build-order
+++ b/_bashbrew-list-build-order
@@ -1,2 +1,4 @@
 ghost:alpine
 ghost:latest
+ghost:next
+ghost:next-alpine
diff --git a/ghost_alpine/Dockerfile b/ghost_alpine/Dockerfile
index 7ae8a11..533fef6 100644
--- a/ghost_alpine/Dockerfile
+++ b/ghost_alpine/Dockerfile
@@ -86,7 +86,7 @@ RUN set -eux; \
 ENV GHOST_INSTALL=/var/lib/ghost
 ENV GHOST_CONTENT=/var/lib/ghost/content
 
-ENV GHOST_VERSION=6.60.0
+ENV GHOST_VERSION=6.61.0
 
 RUN set -eux; \
 	mkdir -p "$GHOST_INSTALL"; \
diff --git a/ghost_latest/Dockerfile b/ghost_latest/Dockerfile
index 4374f60..4b92495 100644
--- a/ghost_latest/Dockerfile
+++ b/ghost_latest/Dockerfile
@@ -88,7 +88,7 @@ RUN set -eux; \
 ENV GHOST_INSTALL=/var/lib/ghost
 ENV GHOST_CONTENT=/var/lib/ghost/content
 
-ENV GHOST_VERSION=6.60.0
+ENV GHOST_VERSION=6.61.0
 
 RUN set -eux; \
 	mkdir -p "$GHOST_INSTALL"; \
diff --git a/ghost_alpine/Dockerfile b/ghost_next-alpine/Dockerfile
similarity index 28%
copy from ghost_alpine/Dockerfile
copy to ghost_next-alpine/Dockerfile
index 7ae8a11..6df162a 100644
--- a/ghost_alpine/Dockerfile
+++ b/ghost_next-alpine/Dockerfile
@@ -6,20 +6,11 @@
 
 FROM node:22-alpine3.23
 
-RUN apk add --no-cache \
-# add "bash" for "[["
-		bash
-
-# grab gosu for easy step-down from root
+# grab gosu for easy step-down from root (ahead of the Ghost install so it stays cached)
 # https://github.com/tianon/gosu/releases
 ENV GOSU_VERSION=1.19
 RUN set -eux; \
-	\
-	apk add --no-cache --virtual .gosu-deps \
-		ca-certificates \
-		dpkg \
-		gnupg \
-	; \
+	apk add --no-cache --virtual .gosu-deps ca-certificates dpkg gnupg; \
 	\
 	dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
 	wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
@@ -32,97 +23,92 @@ RUN set -eux; \
 	gpgconf --kill all; \
 	rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
 	\
-# clean up fetch dependencies
 	apk del --no-network .gosu-deps; \
 	\
 	chmod +x /usr/local/bin/gosu; \
-# verify that the binary works
 	gosu --version; \
 	gosu nobody true
 
+# the node image claims uid/gid 1000; hand it to Ghost instead, keeping the uid the CLI-based image
+# ran as so existing bind mounts still work. "deluser" exists on both bases, so this needs neither
+# usermod/groupmod (absent on Alpine) nor a home dir -- the install below creates it.
+RUN set -eux; \
+	deluser node; \
+	rm -rf /home/node; \
+	addgroup -g 1000 ghost; \
+	adduser -u 1000 -G ghost -h /home/ghost -s /bin/sh -H -D ghost
+
 ENV NODE_ENV=production
 
-# Ghost-CLI is installed from source rather than "npm install -g ghost-cli" so that
-# pnpm can resolve the dependency tree from the lockfile committed alongside the tag
-# ("npm install -g" ignores lockfiles entirely, so the tree it produces varies by build date)
-ENV GHOST_CLI_VERSION=1.32.2
-ENV GHOST_CLI_SHA=5e0b07b7619207459956dd9728bafc5cd7d15f01
-ENV GHOST_CLI_INSTALL=/usr/local/lib/ghost-cli
+ENV GHOST_INSTALL=/home/ghost
+ENV GHOST_CONTENT=/home/ghost/content
+
+ENV GHOST_VERSION=6.61.0
+
+# resolved by "versions.sh" so a build cannot pick up a different tarball than the one reviewed
+ENV GHOST_TARBALL=https://github.com/TryGhost/Ghost/releases/download/v6.61.0/ghost-6.61.0.tgz
+ENV GHOST_SHA256=d7a4de32641979fd57fa98bd29c128e406aa5421f3dadc04ffe28cb96507e69c
+
 RUN set -eux; \
-	\
-	apk add --no-cache --virtual .ghost-cli-deps git; \
-	\
+# Ghost pins its pnpm by hash in "packageManager", so corepack fetches exactly that one
 	corepack enable; \
 	\
-# corepack's downloads, pnpm's metadata cache and pnpm's store all derive their location
-# from these, so pointing them at /tmp keeps every throwaway byte in one place
+# everything under the install dir is created by "ghost" rather than chowned afterwards: a recursive
+# chown would copy the whole tree into a new layer
+	mkdir -p "$GHOST_INSTALL"; \
+	chown ghost:ghost "$GHOST_INSTALL"; \
+	cd "$GHOST_INSTALL"; \
+	\
+# corepack downloads and pnpm's cache/store derive their location from these; /tmp keeps them out of
+# the install dir, and they are removed below
 	export XDG_CACHE_HOME=/tmp/xdg-cache XDG_DATA_HOME=/tmp/xdg-data; \
 	\
-	mkdir -p "$GHOST_CLI_INSTALL"; \
-	cd "$GHOST_CLI_INSTALL"; \
-	git init --quiet .; \
-	git remote add origin https://github.com/TryGhost/Ghost-CLI.git; \
-# fetching the commit by hash means git's own object verification pins the source
-# (a moved tag or a re-rolled release tarball cannot change what we get)
-	git fetch --quiet --depth 1 origin "$GHOST_CLI_SHA"; \
-	git checkout --quiet FETCH_HEAD; \
-	[ "$(node -p 'require("./package.json").version')" = "$GHOST_CLI_VERSION" ]; \
+	gosu ghost wget -O ghost.tgz "$GHOST_TARBALL"; \
+	echo "$GHOST_SHA256  ghost.tgz" | sha256sum -c -; \
+# the release tarball has no leading "package/" component, unlike an npm pack
+	gosu ghost tar --extract --file ghost.tgz; \
+	rm ghost.tgz; \
+	\
+# the tarball ships a pruned lockfile, so the tree is installed exactly, not re-solved per build
+	gosu ghost pnpm install --prod --frozen-lockfile; \
 	\
-# "--frozen-lockfile" is the point of all this: it installs pnpm-lock.yaml exactly, or fails
-	pnpm install --prod --frozen-lockfile; \
+# install-time inputs only: package.json points 18 deps at "file:components/*.tgz", but pnpm
+# extracts them into its virtual store and nothing resolves back here afterwards
+	gosu ghost rm -rf "$GHOST_INSTALL/components"; \
 	\
-	ln -s "$GHOST_CLI_INSTALL/bin/ghost" /usr/local/bin/ghost; \
+# Ghost's own pruner, as used by its production image: drops dependency TypeScript, sourcemaps,
+# READMEs and vendored C/C++ (keeping licences and prebuilt .node). The tarball already has the
+# "archive" profile applied, so this is mostly node_modules. Node 22 strips the .mts types itself.
+	gosu ghost node scripts/prune.mts "$GHOST_INSTALL" --profile=image; \
 	\
-# drop what "npm publish" would not have shipped (see "files" in Ghost-CLI's package.json)
-	rm -rf .git test .github; \
+# make a config.json symlink for NODE_ENV=development (config.production.json is copied in below)
+	gosu ghost ln -s config.production.json "$GHOST_INSTALL/config.development.json"; \
 	\
-	apk del --no-network .ghost-cli-deps; \
+# need to save initial content for pre-seeding empty volumes
+	gosu ghost mv "$GHOST_INSTALL/content" "$GHOST_INSTALL/content.orig"; \
+	gosu ghost mkdir -p "$GHOST_INSTALL/content"; \
 	\
+# "corepack enable" above repointed the yarn symlinks at corepack, orphaning the standalone copy.
+# This does not shrink the image -- those bytes live in a base layer and only get whited out -- but
+# it keeps a dead tree out of the runtime filesystem
+	rm -rf /opt/yarn-*; \
 	rm -rf /tmp/xdg-cache /tmp/xdg-data; \
 	npm cache clean --force; \
 	\
-	ghost --version
+# test that the optional dependencies are installed and loadable
+	node --version; \
+	gosu ghost node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");'
 
-ENV GHOST_INSTALL=/var/lib/ghost
-ENV GHOST_CONTENT=/var/lib/ghost/content
-
-ENV GHOST_VERSION=6.60.0
+# Ghost-CLI used to generate this. Everything in it is overridable via Ghost's "__" env vars, and
+# "process" is deliberately absent -- it only ever told Ghost-CLI which process manager to use
+COPY --chown=ghost:ghost config.production.json $GHOST_INSTALL/
 
 RUN set -eux; \
-	mkdir -p "$GHOST_INSTALL"; \
-	chown node:node "$GHOST_INSTALL"; \
-	\
-	apk add --no-cache --virtual .build-deps-ghost g++ linux-headers make python3 py3-setuptools; \
-	\
-	gosu node ghost install "$GHOST_VERSION" --db mysql --dbhost mysql --no-prompt --no-stack --no-setup --dir "$GHOST_INSTALL"; \
-	\
-# Tell Ghost to listen on all ips and not prompt for additional configuration
-	cd "$GHOST_INSTALL"; \
-	gosu node ghost config --no-prompt --ip '::' --port 2368 --url 'http://localhost:2368'; \
-	gosu node ghost config paths.contentPath "$GHOST_CONTENT"; \
-	\
-# make a config.json symlink for NODE_ENV=development (and sanity check that it's correct)
-	gosu node ln -s config.production.json "$GHOST_INSTALL/config.development.json"; \
-	readlink -f "$GHOST_INSTALL/config.development.json"; \
-	\
-# need to save initial content for pre-seeding empty volumes
-	mv "$GHOST_CONTENT" "$GHOST_INSTALL/content.orig"; \
-	mkdir -p "$GHOST_CONTENT"; \
-	chown node:node "$GHOST_CONTENT"; \
+# a mount point, so it stays writable whatever uid ends up owning what gets mounted over it
 	chmod 1777 "$GHOST_CONTENT"; \
-	\
-	apk del --no-network .build-deps-ghost; \
-	\
-	gosu node pnpm store prune; \
-	gosu node npm cache clean --force; \
-	npm cache clean --force; \
-# none of these are needed to run Ghost: corepack re-downloads pnpm on demand and node-gyp
-# only matters while compiling native modules, which is finished by this point
-	rm -rf /home/node/.cache/node/corepack /home/node/.cache/node-gyp /home/node/.cache/pnpm; \
-	\
-	# test that the optional dependencies are installed and loadable
-	cd current; \
-	gosu node node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");'
+	cd "$GHOST_INSTALL"; \
+	node -e 'JSON.parse(require("fs").readFileSync("config.production.json"))'; \
+	[ "$(readlink -f config.development.json)" = "$GHOST_INSTALL/config.production.json" ]
 
 WORKDIR $GHOST_INSTALL
 VOLUME $GHOST_CONTENT
@@ -131,4 +117,4 @@ COPY docker-entrypoint.sh /usr/local/bin/
 ENTRYPOINT ["docker-entrypoint.sh"]
 
 EXPOSE 2368
-CMD ["node", "current/index.js"]
+CMD ["node", "index.js"]
diff --git a/ghost_next-alpine/config.production.json b/ghost_next-alpine/config.production.json
new file mode 100644
index 0000000..31ec76a
--- /dev/null
+++ b/ghost_next-alpine/config.production.json
@@ -0,0 +1,22 @@
+{
+  "url": "http://localhost:2368",
+  "server": {
+    "port": 2368,
+    "host": "::"
+  },
+  "mail": {
+    "transport": "Direct"
+  },
+  "logging": {
+    "transports": [
+      "file",
+      "stdout"
+    ]
+  },
+  "security": {
+    "staffDeviceVerification": true
+  },
+  "paths": {
+    "contentPath": "/home/ghost/content"
+  }
+}
diff --git a/ghost_next-alpine/docker-entrypoint.sh b/ghost_next-alpine/docker-entrypoint.sh
new file mode 100755
index 0000000..bafa8d6
--- /dev/null
+++ b/ghost_next-alpine/docker-entrypoint.sh
@@ -0,0 +1,96 @@
+#!/bin/sh
+set -e
+
+# POSIX sh, not bash: avoids an Alpine bash package just for "[[ ... ]]"
+case "$*" in
+	node*index.js*) isGhost=1 ;;
+	*) isGhost= ;;
+esac
+
+# ghost:6 installed to /var/lib/ghost; this image uses /home/ghost. An old -v target would boot an
+# empty site while the real content sat unread, so: content there is fatal, an empty mount warns.
+OLD_INSTALL='/var/lib/ghost'
+OLD_CONTENT="$OLD_INSTALL/content"
+
+_is_mountpoint() {
+	# BusyBox "mountpoint" compares only dev/ino, so it misses bind mounts -- i.e. nearly every
+	# Docker volume, and exactly what is being looked for here. Fall back to the mount table.
+	# https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh
+	mountpoint -q "$1" 2>/dev/null \
+	|| awk -v dir="$1" '$5 == dir { found = 1 } END { exit !found }' /proc/self/mountinfo 2>/dev/null
+}
+
+_has_ghost_content() {
+	[ -d "$1" ] || return 1
+	# any of these means a real site, not the empty dir Docker creates for a mount
+	for marker in "$1"/data/*.db "$1"/settings/routes.yaml "$1"/images/* "$1"/themes/*; do
+		if [ -e "$marker" ]; then
+			return 0
+		fi
+	done
+	return 1
+}
+
+# the root pass below re-execs through gosu, so a marker keeps these from printing twice
+if [ -n "$isGhost" ] && [ -z "${GHOST_OLD_PATH_CHECKED:-}" ]; then
+	export GHOST_OLD_PATH_CHECKED=1
+
+	if _has_ghost_content "$OLD_CONTENT" || [ -e "$OLD_INSTALL/.ghost-cli" ]; then
+		cat >&2 <<-EOE
+			Error: found existing Ghost content at $OLD_CONTENT, but this image stores
+			       Ghost in /home/ghost.
+
+			       ghost:6 and earlier installed to /var/lib/ghost. This tag moves that to
+			       /home/ghost, so the content mounted at the old path is not being read.
+			       Starting would create an empty site and leave that content untouched, so
+			       this container will not start.
+
+			       Point the mount at the new location instead:
+
+			           -v ghost_content:/home/ghost/content
+
+			       rather than:
+
+			           -v ghost_content:$OLD_CONTENT
+
+			       Nothing at $OLD_INSTALL has been modified.
+		EOE
+		exit 1
+	fi
+
+	oldMount=
+	if _is_mountpoint "$OLD_CONTENT"; then
+		oldMount="$OLD_CONTENT"
+	elif _is_mountpoint "$OLD_INSTALL"; then
+		oldMount="$OLD_INSTALL"
+	fi
+	if [ -n "$oldMount" ]; then
+		cat >&2 <<-EOW
+			Warning: something is mounted at $oldMount, but this image stores Ghost in
+			         /home/ghost, so that mount is unused.
+
+			         It looks empty, so this is most likely a stale -v left over from ghost:6.
+			         Move it to /home/ghost/content.
+		EOW
+	fi
+fi
+
+# allow the container to be started with `--user`
+if [ -n "$isGhost" ] && [ "$(id -u)" = '0' ]; then
+	find "$GHOST_CONTENT" \! -user ghost -exec chown ghost '{}' +
+	exec gosu ghost "$0" "$@"
+fi
+
+if [ -n "$isGhost" ]; then
+	baseDir="$GHOST_INSTALL/content.orig"
+	for src in "$baseDir"/*/ "$baseDir"/themes/*; do
+		src="${src%/}"
+		target="$GHOST_CONTENT/${src#$baseDir/}"
+		mkdir -p "$(dirname "$target")"
+		if [ ! -e "$target" ]; then
+			tar -cC "$(dirname "$src")" "$(basename "$src")" | tar -xC "$(dirname "$target")"
+		fi
+	done
+fi
+
+exec "$@"
diff --git a/ghost_latest/Dockerfile b/ghost_next/Dockerfile
similarity index 32%
copy from ghost_latest/Dockerfile
copy to ghost_next/Dockerfile
index 4374f60..bd65286 100644
--- a/ghost_latest/Dockerfile
+++ b/ghost_next/Dockerfile
@@ -6,13 +6,13 @@
 
 FROM node:22-bookworm-slim
 
-# grab gosu for easy step-down from root
+# grab gosu for easy step-down from root (ahead of the Ghost install so it stays cached)
 # https://github.com/tianon/gosu/releases
 ENV GOSU_VERSION=1.19
 RUN set -eux; \
-# save list of currently installed packages for later so we can clean up
 	savedAptMark="$(apt-mark showmanual)"; \
 	apt-get update; \
+# unlike the Alpine image, "node:*-slim" ships neither wget nor ca-certificates
 	apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
 	\
 	dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
@@ -26,110 +26,114 @@ RUN set -eux; \
 	gpgconf --kill all; \
 	rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
 	\
-# clean up fetch dependencies
 	apt-mark auto '.*' > /dev/null; \
 	[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \
 	apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
 	rm -rf /var/lib/apt/lists/*; \
 	\
 	chmod +x /usr/local/bin/gosu; \
-# verify that the binary works
 	gosu --version; \
 	gosu nobody true
 
+# the node image claims uid/gid 1000; hand it to Ghost instead, keeping the uid the CLI-based image
+# ran as so existing bind mounts still work. "deluser" exists on both bases, so this needs neither
+# usermod/groupmod (absent on Alpine) nor a home dir -- the install below creates it.
+RUN set -eux; \
+	deluser node; \
+	rm -rf /home/node; \
+	groupadd --gid 1000 ghost; \
+	useradd --uid 1000 --gid 1000 --home-dir /home/ghost --no-create-home --shell /bin/bash ghost
+
 ENV NODE_ENV=production
 
-# Ghost-CLI is installed from source rather than "npm install -g ghost-cli" so that
-# pnpm can resolve the dependency tree from the lockfile committed alongside the tag
-# ("npm install -g" ignores lockfiles entirely, so the tree it produces varies by build date)
-ENV GHOST_CLI_VERSION=1.32.2
-ENV GHOST_CLI_SHA=5e0b07b7619207459956dd9728bafc5cd7d15f01
-ENV GHOST_CLI_INSTALL=/usr/local/lib/ghost-cli
+ENV GHOST_INSTALL=/home/ghost
+ENV GHOST_CONTENT=/home/ghost/content
+
+ENV GHOST_VERSION=6.61.0
+
+# resolved by "versions.sh" so a build cannot pick up a different tarball than the one reviewed
+ENV GHOST_TARBALL=https://github.com/TryGhost/Ghost/releases/download/v6.61.0/ghost-6.61.0.tgz
+ENV GHOST_SHA256=d7a4de32641979fd57fa98bd29c128e406aa5421f3dadc04ffe28cb96507e69c
+
 RUN set -eux; \
-	\
 	savedAptMark="$(apt-mark showmanual)"; \
 	apt-get update; \
-	apt-get install -y --no-install-recommends ca-certificates git; \
-	\
+	apt-get install -y --no-install-recommends \
+		ca-certificates \
+		wget \
+# only arm32v7 needs these: it has no prebuilt better-sqlite3 or re2, so they fall back to node-gyp
+		g++ \
+		make \
+		python3 \
+	; \
+	\
+# Ghost pins its pnpm by hash in "packageManager", so corepack fetches exactly that one
 	corepack enable; \
 	\
-# corepack's downloads, pnpm's metadata cache and pnpm's store all derive their location
-# from these, so pointing them at /tmp keeps every throwaway byte in one place
+# everything under the install dir is created by "ghost" rather than chowned afterwards: a recursive
+# chown would copy the whole tree into a new layer
+	mkdir -p "$GHOST_INSTALL"; \
+	chown ghost:ghost "$GHOST_INSTALL"; \
+	cd "$GHOST_INSTALL"; \
+	\
+# corepack downloads and pnpm's cache/store derive their location from these; /tmp keeps them out of
+# the install dir, and they are removed below
 	export XDG_CACHE_HOME=/tmp/xdg-cache XDG_DATA_HOME=/tmp/xdg-data; \
 	\
-	mkdir -p "$GHOST_CLI_INSTALL"; \
-	cd "$GHOST_CLI_INSTALL"; \
-	git init --quiet .; \
-	git remote add origin https://github.com/TryGhost/Ghost-CLI.git; \
-# fetching the commit by hash means git's own object verification pins the source
-# (a moved tag or a re-rolled release tarball cannot change what we get)
-	git fetch --quiet --depth 1 origin "$GHOST_CLI_SHA"; \
-	git checkout --quiet FETCH_HEAD; \
-	[ "$(node -p 'require("./package.json").version')" = "$GHOST_CLI_VERSION" ]; \
+	gosu ghost wget -O ghost.tgz "$GHOST_TARBALL"; \
+	echo "$GHOST_SHA256  ghost.tgz" | sha256sum -c -; \
+# the release tarball has no leading "package/" component, unlike an npm pack
+	gosu ghost tar --extract --file ghost.tgz; \
+	rm ghost.tgz; \
 	\
-# "--frozen-lockfile" is the point of all this: it installs pnpm-lock.yaml exactly, or fails
-	pnpm install --prod --frozen-lockfile; \
+# the tarball ships a pruned lockfile, so the tree is installed exactly, not re-solved per build
+	gosu ghost pnpm install --prod --frozen-lockfile; \
 	\
-	ln -s "$GHOST_CLI_INSTALL/bin/ghost" /usr/local/bin/ghost; \
+# install-time inputs only: package.json points 18 deps at "file:components/*.tgz", but pnpm
+# extracts them into its virtual store and nothing resolves back here afterwards
+	gosu ghost rm -rf "$GHOST_INSTALL/components"; \
 	\
-# drop what "npm publish" would not have shipped (see "files" in Ghost-CLI's package.json)
-	rm -rf .git test .github; \
+# Ghost's own pruner, as used by its production image: drops dependency TypeScript, sourcemaps,
+# READMEs and vendored C/C++ (keeping licences and prebuilt .node). The tarball already has the
+# "archive" profile applied, so this is mostly node_modules. Node 22 strips the .mts types itself.
+	gosu ghost node scripts/prune.mts "$GHOST_INSTALL" --profile=image; \
 	\
-	apt-mark auto '.*' > /dev/null; \
-	[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \
-	apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
-	rm -rf /var/lib/apt/lists/*; \
+# make a config.json symlink for NODE_ENV=development (config.production.json is copied in below)
+	gosu ghost ln -s config.production.json "$GHOST_INSTALL/config.development.json"; \
+	\
+# need to save initial content for pre-seeding empty volumes
+	gosu ghost mv "$GHOST_INSTALL/content" "$GHOST_INSTALL/content.orig"; \
+	gosu ghost mkdir -p "$GHOST_INSTALL/content"; \
 	\
+# "corepack enable" above repointed the yarn symlinks at corepack, orphaning the standalone copy.
+# This does not shrink the image -- those bytes live in a base layer and only get whited out -- but
+# it keeps a dead tree out of the runtime filesystem
+	rm -rf /opt/yarn-*; \
 	rm -rf /tmp/xdg-cache /tmp/xdg-data; \
 	npm cache clean --force; \
 	\
-	ghost --version
-
-ENV GHOST_INSTALL=/var/lib/ghost
-ENV GHOST_CONTENT=/var/lib/ghost/content
-
-ENV GHOST_VERSION=6.60.0
-
-RUN set -eux; \
-	mkdir -p "$GHOST_INSTALL"; \
-	chown node:node "$GHOST_INSTALL"; \
-	\
-	savedAptMark="$(apt-mark showmanual)"; \
-	apt-get update; \
-	apt-get install -y --no-install-recommends g++ make python3; \
-	\
-	gosu node ghost install "$GHOST_VERSION" --db mysql --dbhost mysql --no-prompt --no-stack --no-setup --dir "$GHOST_INSTALL"; \
-	\
-# Tell Ghost to listen on all ips and not prompt for additional configuration
-	cd "$GHOST_INSTALL"; \
-	gosu node ghost config --no-prompt --ip '::' --port 2368 --url 'http://localhost:2368'; \
-	gosu node ghost config paths.contentPath "$GHOST_CONTENT"; \
-	\
-# make a config.json symlink for NODE_ENV=development (and sanity check that it's correct)
-	gosu node ln -s config.production.json "$GHOST_INSTALL/config.development.json"; \
-	readlink -f "$GHOST_INSTALL/config.development.json"; \
-	\
-# need to save initial content for pre-seeding empty volumes
-	mv "$GHOST_CONTENT" "$GHOST_INSTALL/content.orig"; \
-	mkdir -p "$GHOST_CONTENT"; \
-	chown node:node "$GHOST_CONTENT"; \
-	chmod 1777 "$GHOST_CONTENT"; \
-	\
 	apt-mark auto '.*' > /dev/null; \
 	[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \
+# node's image ships no CA bundle of its own, and the purge below would take the one installed
+# above with it; Node has a bundled store, but anything shelling out needs the system one
+	apt-mark manual ca-certificates > /dev/null; \
 	apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
 	rm -rf /var/lib/apt/lists/*; \
 	\
-	gosu node pnpm store prune; \
-	gosu node npm cache clean --force; \
-	npm cache clean --force; \
-# none of these are needed to run Ghost: corepack re-downloads pnpm on demand and node-gyp
-# only matters while compiling native modules, which is finished by this point
-	rm -rf /home/node/.cache/node/corepack /home/node/.cache/node-gyp /home/node/.cache/pnpm; \
-	\
-	# test that the optional dependencies are installed and loadable
-	cd current; \
-	gosu node node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");'
+# test that the optional dependencies are installed and loadable
+	node --version; \
+	gosu ghost node -e 'require("better-sqlite3"); if (!require("@tryghost/image-transform").canTransformFiles()) throw new Error("sharp not installed");'
+
+# Ghost-CLI used to generate this. Everything in it is overridable via Ghost's "__" env vars, and
+# "process" is deliberately absent -- it only ever told Ghost-CLI which process manager to use
+COPY --chown=ghost:ghost config.production.json $GHOST_INSTALL/
+
+RUN set -eux; \
+# a mount point, so it stays writable whatever uid ends up owning what gets mounted over it
+	chmod 1777 "$GHOST_CONTENT"; \
+	cd "$GHOST_INSTALL"; \
+	node -e 'JSON.parse(require("fs").readFileSync("config.production.json"))'; \
+	[ "$(readlink -f config.development.json)" = "$GHOST_INSTALL/config.production.json" ]
 
 WORKDIR $GHOST_INSTALL
 VOLUME $GHOST_CONTENT
@@ -138,4 +142,4 @@ COPY docker-entrypoint.sh /usr/local/bin/
 ENTRYPOINT ["docker-entrypoint.sh"]
 
 EXPOSE 2368
-CMD ["node", "current/index.js"]
+CMD ["node", "index.js"]
diff --git a/ghost_next/config.production.json b/ghost_next/config.production.json
new file mode 100644
index 0000000..31ec76a
--- /dev/null
+++ b/ghost_next/config.production.json
@@ -0,0 +1,22 @@
+{
+  "url": "http://localhost:2368",
+  "server": {
+    "port": 2368,
+    "host": "::"
+  },
+  "mail": {
+    "transport": "Direct"
+  },
+  "logging": {
+    "transports": [
+      "file",
+      "stdout"
+    ]
+  },
+  "security": {
+    "staffDeviceVerification": true
+  },
+  "paths": {
+    "contentPath": "/home/ghost/content"
+  }
+}
diff --git a/ghost_next/docker-entrypoint.sh b/ghost_next/docker-entrypoint.sh
new file mode 100755
index 0000000..bafa8d6
--- /dev/null
+++ b/ghost_next/docker-entrypoint.sh
@@ -0,0 +1,96 @@
+#!/bin/sh
+set -e
+
+# POSIX sh, not bash: avoids an Alpine bash package just for "[[ ... ]]"
+case "$*" in
+	node*index.js*) isGhost=1 ;;
+	*) isGhost= ;;
+esac
+
+# ghost:6 installed to /var/lib/ghost; this image uses /home/ghost. An old -v target would boot an
+# empty site while the real content sat unread, so: content there is fatal, an empty mount warns.
+OLD_INSTALL='/var/lib/ghost'
+OLD_CONTENT="$OLD_INSTALL/content"
+
+_is_mountpoint() {
+	# BusyBox "mountpoint" compares only dev/ino, so it misses bind mounts -- i.e. nearly every
+	# Docker volume, and exactly what is being looked for here. Fall back to the mount table.
+	# https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh
+	mountpoint -q "$1" 2>/dev/null \
+	|| awk -v dir="$1" '$5 == dir { found = 1 } END { exit !found }' /proc/self/mountinfo 2>/dev/null
+}
+
+_has_ghost_content() {
+	[ -d "$1" ] || return 1
+	# any of these means a real site, not the empty dir Docker creates for a mount
+	for marker in "$1"/data/*.db "$1"/settings/routes.yaml "$1"/images/* "$1"/themes/*; do
+		if [ -e "$marker" ]; then
+			return 0
+		fi
+	done
+	return 1
+}
+
+# the root pass below re-execs through gosu, so a marker keeps these from printing twice
+if [ -n "$isGhost" ] && [ -z "${GHOST_OLD_PATH_CHECKED:-}" ]; then
+	export GHOST_OLD_PATH_CHECKED=1
+
+	if _has_ghost_content "$OLD_CONTENT" || [ -e "$OLD_INSTALL/.ghost-cli" ]; then
+		cat >&2 <<-EOE
+			Error: found existing Ghost content at $OLD_CONTENT, but this image stores
+			       Ghost in /home/ghost.
+
+			       ghost:6 and earlier installed to /var/lib/ghost. This tag moves that to
+			       /home/ghost, so the content mounted at the old path is not being read.
+			       Starting would create an empty site and leave that content untouched, so
+			       this container will not start.
+
+			       Point the mount at the new location instead:
+
+			           -v ghost_content:/home/ghost/content
+
+			       rather than:
+
+			           -v ghost_content:$OLD_CONTENT
+
+			       Nothing at $OLD_INSTALL has been modified.
+		EOE
+		exit 1
+	fi
+
+	oldMount=
+	if _is_mountpoint "$OLD_CONTENT"; then
+		oldMount="$OLD_CONTENT"
+	elif _is_mountpoint "$OLD_INSTALL"; then
+		oldMount="$OLD_INSTALL"
+	fi
+	if [ -n "$oldMount" ]; then
+		cat >&2 <<-EOW
+			Warning: something is mounted at $oldMount, but this image stores Ghost in
+			         /home/ghost, so that mount is unused.
+
+			         It looks empty, so this is most likely a stale -v left over from ghost:6.
+			         Move it to /home/ghost/content.
+		EOW
+	fi
+fi
+
+# allow the container to be started with `--user`
+if [ -n "$isGhost" ] && [ "$(id -u)" = '0' ]; then
+	find "$GHOST_CONTENT" \! -user ghost -exec chown ghost '{}' +
+	exec gosu ghost "$0" "$@"
+fi
+
+if [ -n "$isGhost" ]; then
+	baseDir="$GHOST_INSTALL/content.orig"
+	for src in "$baseDir"/*/ "$baseDir"/themes/*; do
+		src="${src%/}"
+		target="$GHOST_CONTENT/${src#$baseDir/}"
+		mkdir -p "$(dirname "$target")"
+		if [ ! -e "$target" ]; then
+			tar -cC "$(dirname "$src")" "$(basename "$src")" | tar -xC "$(dirname "$target")"
+		fi
+	done
+fi
+
+exec "$@"

Relevant Maintainers:

@yosifkit
yosifkit merged commit 7133945 into docker-library:master Aug 28, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Ghost Docker Image] - Labeling breaking changes

4 participants