pnpm workspaces were chosen over npm or yarn for several reasons:
- Disk efficiency: pnpm uses a content-addressable store and hard links, so shared dependencies (like Vite, PostCSS) are stored once on disk regardless of how many themes use them.
- Strict dependency resolution: Unlike npm, pnpm doesn't hoist packages to the root by default, preventing themes from accidentally importing undeclared dependencies.
- Native workspace protocol:
pnpm -r --parallelandpnpm --filter @themes/theme-nameprovide ergonomic commands for running scripts across specific themes. - Speed: pnpm installs are consistently faster than npm or yarn classic due to the linking strategy.
Ghost is a server-rendered application using Handlebars templates. The browser loads fully rendered HTML from Ghost, not from a Vite dev server. This means:
- Vite's dev server is incompatible with Ghost's rendering model — there's no
index.htmlfor Vite to serve. vite build --watchis the correct mode: Vite watches source files, rebuilds CSS/JS toassets/built/, and Ghost's livereload picks up the changes.- Vite replaces Gulp/Rollup as the build tool. It handles JS bundling (replacing gulp-concat + gulp-uglify), PostCSS processing (replacing gulp-postcss), and file watching — all in a single tool with faster rebuilds.
- Output filenames are deterministic without content hashes because Ghost templates reference assets via
{{asset "built/index.js"}}— a fixed path. Ghost's{{asset}}helper appends its own cache-busting query parameter.
When Ghost runs with NODE_ENV=development:
- Ghost injects a livereload script into every page via
{{ghost_foot}}. - Ghost watches the active theme's directory for file changes.
- When
.hbsfiles change, the browser automatically refreshes. - When
assets/built/files change (from a Vite rebuild), livereload also triggers a refresh.
This means no additional livereload tooling is needed — no browser extensions, no gulp-livereload, no BrowserSync. Ghost handles it natively.
The dev service (node:22-alpine) runs pnpm dev inside Docker so developers don't need Node.js or pnpm installed locally. It uses corepack (bundled with Node 22) to bootstrap pnpm, installs workspace dependencies, then runs vite build --watch on all themes.
A local bind mount (./data/dev_node_modules) mounts over /workspace/node_modules inside the container. This prevents pnpm's symlinked content-addressable store from leaking onto the host as broken symlinks. The volume persists across container restarts, so subsequent pnpm install runs are near-instant.
The dev service has no dependency on the Ghost or MySQL services — it only compiles assets. The compiled files land in themes/*/assets/built/ via the shared bind mount, where Ghost picks them up and triggers livereload.
The official Ghost Docker image requires a MySQL-compatible database:
- Ghost 6 officially supports MySQL 8.0.
- SQLite is only suitable for single-user/testing scenarios.
- MySQL 8.0 is the widely deployed LTS release with broad tooling support.
This is not a preference — it's a requirement of the Ghost Docker image for reliable operation.
If you mount a single named volume at /var/lib/ghost/content, Docker creates a volume layer that shadows everything underneath it, including the themes/ subdirectory. Any bind mounts for individual themes at /var/lib/ghost/content/themes/theme-name would be invisible because the parent named volume takes precedence.
Instead, we mount named volumes only on the specific subdirectories that need persistence:
ghost_content_data:/var/lib/ghost/content/data # SQLite/MySQL data files
ghost_content_images:/var/lib/ghost/content/images # Uploaded images
ghost_content_logs:/var/lib/ghost/content/logs # Ghost log files
The themes/ directory inside the container is left unmanaged by Docker volumes, so individual theme bind mounts work correctly:
./themes/Source:/var/lib/ghost/content/themes/Source
./themes/theme-one:/var/lib/ghost/content/themes/theme-one
./themes/theme-two:/var/lib/ghost/content/themes/theme-two
This ensures that local file edits are immediately visible inside the container.
gscan is Ghost's official theme validation tool:
- It checks for required files (default.hbs, index.hbs, post.hbs, etc.)
- It validates Handlebars helper usage against the Ghost API
- It reports errors (blocking) and warnings (non-blocking)
- It exits with a non-zero code on errors, making it CI-safe
- It runs locally via
npx gscan .— no Ghost instance needed
- Errors (red): Must be fixed — Ghost will reject themes with errors.
- Warnings (yellow): Recommendations — the theme will still work but may have issues.
- Recommendations (blue): Best practices — optional improvements.
- Copy an existing theme directory:
cp -r themes/theme-one themes/my-theme - Update
nameinthemes/my-theme/package.jsontomy-theme - Add a bind mount in
docker-compose.yml:- ./themes/my-theme:/var/lib/ghost/content/themes/my-theme - Restart services:
docker compose down && docker compose up -d - Activate the theme in Ghost Admin: Settings > Design > Change theme.
- Target it with pnpm:
pnpm --filter my-theme dev