Skip to content

Added production Docker image builds to CI#26360

Open
rob-ghost wants to merge 1 commit intomainfrom
feat/production-docker-images
Open

Added production Docker image builds to CI#26360
rob-ghost wants to merge 1 commit intomainfrom
feat/production-docker-images

Conversation

@rob-ghost
Copy link
Contributor

@rob-ghost rob-ghost commented Feb 11, 2026

closes https://linear.app/ghost/issue/BER-3292/add-production-docker-image-build-to-ghost-ci

Problem

Ghost CI builds a dev image for E2E tests, but the production image lives in Ghost-Moya. Moya clones Ghost, rebuilds from source every commit (~12 min), and ships an artifact that was never tested by CI. We need Ghost to own its own production image so what we test is what we ship.

Proposal

Add a CI job that builds and pushes two production Docker images to GHCR on every commit:

  • ghcr.io/tryghost/ghost-core — server + production deps, no admin (base for Ghost-Pro)
  • ghcr.io/tryghost/ghost — core + built admin (self-hosters)

The Dockerfile mirrors Moya's proven production setup (bookworm-slim, jemalloc, sqlite3, ghost user). A pack:standalone script in ghost/core wraps npm pack to produce a standalone distribution that works outside the monorepo.

Nothing consumes these images yet. This is laying the foundation.

Not Doing

  • Switching Moya to use these images (BER-3294, separate PR)
  • Running E2E tests against the production image (BER-3293)
  • Multi-stage Docker build (builds on CI runner using existing Nx/yarn cache instead)
  • Fixing Ghost's dependency bloat (@faker-js/faker in production deps, etc.)
  • Version tags — those come from the release workflow, not CI

Trade-offs

  • Host build, not Docker build. We build on the CI runner and COPY into Docker rather than using a multi-stage Dockerfile. This reuses CI's dependency cache (fast) but means the Dockerfile isn't self-contained. Conventional wisdom says multi-stage, but Ghost's monorepo + monobundle + Nx cache make that impractical today.
  • Two images, one job. Both images build sequentially in one job. Parallel jobs would be faster but double the build cost since they'd each need to build assets from scratch.
  • Image size ~1.1-1.3GB. Comparable to the official Ghost Docker Hub image. Mostly node_modules. Not great, but not our problem to solve here.

Rollback

Easy. Delete the CI job and Dockerfile. Nothing depends on these images yet. No data, no migrations, no customer impact.

Blast Radius

None. Purely additive. Existing CI jobs, Moya, and production are completely untouched. The images are pushed to GHCR but nothing reads from them until we explicitly wire up Moya in a follow-up.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

Walkthrough

Adds CI support to build and push production Docker images via a new job job_docker_build_production. Introduces Dockerfile.production implementing a multi-stage build with core and full targets to produce lean runtime and admin-inclusive images. Adds pack:standalone script to ghost/core/package.json to create a packaged distribution and a top-level build:production npm script that runs production build steps (tsc, assets, admin build).

Possibly related issues

  • TryGhost/Toast issue 376 — Implements a CI job and production Dockerfile to build and push production images, matching the proposal in that issue.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding production Docker image builds to the CI workflow.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description clearly describes the changeset: adding production Docker image builds to CI, including the problem, proposal, and trade-offs that align with the code changes (new Dockerfile.production, CI job, and build scripts).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/production-docker-images

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@rob-ghost rob-ghost self-assigned this Feb 11, 2026
@rob-ghost rob-ghost marked this pull request as ready for review February 11, 2026 20:44
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @.github/workflows/ci.yml:
- Around line 1055-1059: The CI step named "Build server and admin assets"
currently invokes nx using "npx nx run ghost:build:tsc", which is inconsistent
with the rest of the workflow and can pick a different nx version; update that
command to use the workspace runner (replace "npx nx run ghost:build:tsc" with
the equivalent "yarn nx run ghost:build:tsc") so it matches other jobs that use
yarn nx and ensures the workspace nx is used.

In `@Dockerfile.production`:
- Around line 27-29: The Dockerfile uses "COPY . ." followed by "RUN rm -rf
core/built/admin" so admin files remain in the earlier layer; fix by changing
the build to avoid copying admin into the core image layer—either create an
intermediate stage (e.g., "context-strip" or "builder") that does "COPY . ."
then removes "core/built/admin" and then COPY --from=that-stage into the "core"
stage, or replace the broad "COPY . ." with explicit COPYs that exclude
"core/built/admin"; update references to the "core" target and the later "full"
stage COPY of "core/built/admin" accordingly so the admin assets are only added
where intended.
- Line 8: The Dockerfile.production currently sets a default ARG NODE_VERSION
which duplicates the value in CI; update the CI build step
(job_docker_build_production in .github/workflows/ci.yml) to explicitly pass the
Node version to Docker (use build-args: NODE_VERSION: <value>) and remove or
leave a non-committal default in Dockerfile.production so the image always uses
the version provided by CI; reference ARG NODE_VERSION in Dockerfile.production
and the job_docker_build_production build step to keep them consistent and avoid
drift.
🧹 Nitpick comments (5)
ghost/core/package.json (1)

24-24: Glob ghost-*.tgz is fragile if stale artifacts exist.

If a previous pack:standalone run failed mid-way (e.g., locally), leftover ghost-*.tgz files or a stale package/ directory could cause unexpected behavior. Consider adding a cleanup prefix:

♻️ Suggested hardening
-    "pack:standalone": "npm pack && tar -xzf ghost-*.tgz && cp ../../yarn.lock package/ && rm ghost-*.tgz",
+    "pack:standalone": "rm -rf package ghost-*.tgz && npm pack && tar -xzf ghost-*.tgz && cp ../../yarn.lock package/ && rm ghost-*.tgz",

This is a no-op in CI (clean workspace) but guards against local reuse issues.

Dockerfile.production (2)

15-23: Add --no-install-recommends to reduce image size.

As flagged by static analysis (Trivy DS-0029), the apt-get install is missing --no-install-recommends. This pulls in unnecessary recommended packages, inflating the image — particularly for build-essential and python3 which are only needed temporarily for the sqlite3 native build.

♻️ Proposed fix
 RUN apt-get update && \
     apt-get install -y \
+        --no-install-recommends \
         build-essential \
         libjemalloc2 \
         python3 && \

31-42: RUN block looks solid overall; minor cleanup suggestion.

The yarn install + sqlite3 native build + build-tool purge pattern is well structured. Two small nits:

  1. Line 35: apt autoremove → consider apt-get autoremove for consistency with apt-get purge on line 34 (the apt CLI prints a warning about unstable CLI in scripts, though it's cosmetic in Docker).
  2. After purging build tools, consider adding rm -rf /var/cache/apt/archives/*.deb to ensure no stale .deb files remain in the layer.

Neither is blocking.

.github/workflows/ci.yml (2)

1031-1034: Consider gating this job on relevant file changes.

Unlike other jobs (e.g., job_acceptance-tests, job_unit-tests), this job has no if condition and will run on every single PR and push — even documentation-only changes. This adds CI time and GHCR storage for every commit.

If "on every commit" is intentional (to always have a fresh image), this is fine. Otherwise, consider adding a condition like:

if: needs.job_setup.outputs.changed_core == 'true' || needs.job_setup.outputs.changed_admin == 'true'

1088-1108: Consider adding OCI labels for consistency with the development image.

The existing job_docker_build (lines 984-988) defines OCI labels (org.opencontainers.image.title, description, vendor, maintainer). The production image metadata steps don't include any labels, which means the images will lack descriptive metadata in the registry.

Not blocking, but worth adding for parity.

@codecov
Copy link

codecov bot commented Feb 11, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.82%. Comparing base (c7a81bc) to head (6d712ed).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #26360   +/-   ##
=======================================
  Coverage   72.81%   72.82%           
=======================================
  Files        1561     1561           
  Lines      120704   120778   +74     
  Branches    14539    14557   +18     
=======================================
+ Hits        87895    87956   +61     
- Misses      31797    31809   +12     
- Partials     1012     1013    +1     
Flag Coverage Δ
admin-tests 52.35% <ø> (ø)
e2e-tests 72.82% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rob-ghost rob-ghost force-pushed the feat/production-docker-images branch 3 times, most recently from 7bad77d to 785aaa1 Compare February 11, 2026 21:14
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @.github/workflows/ci.yml:
- Around line 1064-1073: This step calculates is-fork-pr and should-push but
doesn't persist built images for fork PRs; add a conditional artifact upload
step (using actions/upload-artifact) after the image load/build steps that runs
when is-fork-pr is true (or should-push is false) to save the image tar(s) the
same way job_docker_build does so fork PR images are preserved; use the outputs
from the step id "strategy" (is-fork-pr / should-push) to gate the upload and
match the artifact naming pattern used in job_docker_build.
🧹 Nitpick comments (3)
Dockerfile.production (1)

47-48: Consider the implications of VOLUME declarations for downstream consumers.

Declaring VOLUME /home/ghost/content and VOLUME /home/ghost/log means Docker will create anonymous volumes even when users don't explicitly mount them, which can lead to orphaned volumes over time. This is a common pattern for Ghost but worth documenting for self-hosters who may not expect it.

.github/workflows/ci.yml (2)

1031-1034: Job runs unconditionally on every PR — consider gating on relevant path changes.

Unlike most other jobs in this workflow (which use if: needs.job_setup.outputs.changed_core == 'true' or similar), this job has no if condition. It will run on every PR, including docs-only or unrelated changes, adding ~5-10 minutes of CI time per run.

If the intent is truly "build on every commit," that's fine — but you could at least gate it on changed_any_code to skip markdown-only PRs.

♻️ Suggested condition
   job_docker_build_production:
     name: Build & Push Production Docker Images
     needs: [job_setup]
+    if: needs.job_setup.outputs.changed_any_code == 'true'
     runs-on: ubuntu-latest

1116-1128: PR builds don't read from PR-specific Docker layer cache.

The cache-from only references cache-main, but cache-to writes PR-specific caches (e.g., cache-pr-123). This means subsequent pushes to the same PR won't benefit from the PR's own cache — only from main's cache. The existing job_docker_build (lines 1002-1004) reads from both main and PR caches.

♻️ Suggested fix for both core and full build steps
          cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/ghost-core:cache-main
+            ${{ github.event_name == 'pull_request' && format('type=registry,ref=ghcr.io/{0}/ghost-core:cache-pr-{1}', github.repository_owner, github.event.pull_request.number) || '' }}

Apply the same pattern to the full image build step (line 1141).

@rob-ghost rob-ghost force-pushed the feat/production-docker-images branch from 785aaa1 to 3b73763 Compare February 11, 2026 21:26
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@Dockerfile.production`:
- Around line 15-42: The Dockerfile installs build-essential and python3 in one
RUN and purges them in a later RUN, which leaves their files in earlier image
layers; consolidate the package install, yarn install (the RUN that executes
"yarn install --ignore-scripts --production --prefer-offline" and "(cd
node_modules/sqlite3 && npm run install)"), and the apt-get purge into a single
RUN so build deps are removed in the same layer they were added; ensure
libjemalloc2 remains installed (do not purge it) because it's required at
runtime (LD_PRELOAD), and keep the filesystem operations (mkdir, cp -R content,
chown commands) in that same consolidated RUN so temporary build artifacts are
cleaned before the layer is committed.
🧹 Nitpick comments (2)
.github/workflows/ci.yml (2)

1031-1034: Job runs on every commit regardless of changed files.

Unlike other jobs (e.g., job_unit-tests, job_acceptance-tests) that gate on changed_core, changed_admin, etc., this job has no if condition. It will run even for documentation-only or Tinybird-only changes, consuming CI minutes to build Docker images unnecessarily.

Consider adding a filter, e.g.:

if: needs.job_setup.outputs.changed_core == 'true' || needs.job_setup.outputs.changed_admin == 'true'

1118-1144: PR builds don't restore their own layer cache.

Both cache-from entries (Lines 1129, 1143) only pull from cache-main. Unlike job_docker_build (Lines 1002-1004), there's no fallback to the PR-specific cache. On active PRs with multiple pushes, each build starts from the main-branch cache rather than reusing its own prior layers.

♻️ Suggested fix (core example, same pattern for full)
-          cache-from: type=registry,ref=ghcr.io/${{ steps.strategy.outputs.owner }}/ghost-core:cache-main
+          cache-from: |
+            type=registry,ref=ghcr.io/${{ steps.strategy.outputs.owner }}/ghost-core:cache-main
+            ${{ github.event_name == 'pull_request' && format('type=registry,ref=ghcr.io/{0}/ghost-core:cache-pr-{1}', steps.strategy.outputs.owner, github.event.pull_request.number) || '' }}

@rob-ghost rob-ghost force-pushed the feat/production-docker-images branch from 3b73763 to 30a6951 Compare February 11, 2026 21:43
ref https://linear.app/ghost/issue/BER-3292/add-production-docker-image-build-to-ghost-ci

Ghost CI now builds two production Docker images on every commit:
- ghcr.io/tryghost/ghost-core (server + production deps, no admin)
- ghcr.io/tryghost/ghost (core + built admin assets)

Uses npm pack to create a standalone distribution via monobundle.js,
which bundles private workspace packages as local tarballs. The
Dockerfile mirrors Ghost-Moya's proven production setup (bookworm-slim,
jemalloc, ghost user uid 1000, sqlite3 native build).

Nothing consumes these images yet — this is purely additive. Ghost-Moya
continues building from source until we're ready to switch.
@rob-ghost rob-ghost force-pushed the feat/production-docker-images branch from 30a6951 to 6d712ed Compare February 11, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants