Efficient Ghost theme development using Docker & livereload
When developing Ghost themes inside Docker, getting a fast feedback loop of edit/reload/view can be tricky. Here’s how I set up live reloading and instant theme updates using Docker and Gulp, and a bonus use of Maildev to make SMTP configuration super simple.
This setup was a lifesaver when I started using AI assistants for tasks outside my usual wheelhouse, like wrestling with complex CSS media queries. By adding a specific Theme Development Workflow section to my agent.md, I enabled Antigravity to debug layouts in a Chrome instance, it understood it could see the changes reflected as soon as it made an update to the file.
When you modify theme files (Handlebars, CSS, or JS),the system is configured to reflect those changes immediately. You do not need to manually restart the Docker container or refresh the browser; the gulp watch task handles the injection and reload automatically.
Project Structure Overview #
Here’s an overview of the project root’s structure, in a simplified form. The basic gist is that I have a root directory containing docker things, and a root directory for the theme.
This is what the docker-compose.yml file looks like. I’m including the full file here because I found various examples of this online, but none of them were exactly right for my needs.
services:
ghostdev-web:
image: ghost:latest
restart: unless-stopped
ports:
- 2368:2368
environment:
- database__client=mysql
- database__connection__host=ghostdev-db
- database__connection__database=ghostdb
- database__connection__user=ghost
- database__connection__password=SHUUUSHSECRET
- mail__transport=SMTP
- mail__options__host=maildev-test
- mail__options__port=1025
- mail__options_secure=false
- url=http://mydevbox.local:2368
- NODE_ENV=development
volumes:
- /Users/nick/ghost-test/docker/docker-ghost-content:/var/lib/ghost/content
depends_on:
ghostdev-db:
condition: service_healthy
ghostdev-db:
image: mysql:8.0
restart: unless-stopped
container_name: ghostdev-db
environment:
MYSQL_ROOT_HOST: '%'
MYSQL_ROOT_PASSWORD: ULTRAMEGASECRET
MYSQL_DATABASE: ghostdb
MYSQL_USER: ghost
MYSQL_PASSWORD: SHUUUSHSECRET
volumes:
- /Users/nick/ghost-test/docker/docker-ghost-mysqldb:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -p$$MYSQL_ROOT_PASSWORD || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 60s
maildev-test:
image: maildev/maildev:latest
restart: unless-stopped
ports:
- 1080:1080
- 1025:1025
The key thing to note here is that the Ghost container has /var/lib/ghost/content mounted in the host filesystem as {$PROJECT_ROOT}/docker/docker-ghost-content/. This is important as it gives us easy direct access to the active theme. If we overwrite the files in the active theme directory, it has an immediate effect on the files served by Ghost inside the Docker container. We don’t need to go through the process of uploading another zipfile of the theme via the Ghost admin interface.
The use of Maildev is a little bonus. It’s not directly related to the livereload technique, but there’s a section right at the end talking about why it’s useful.
Gulp #
I based my theme on Casper, which is one of the core example themes available on GitHub. It already comes with a Gulp configuration, so here is an outline of how my livereload changes work.
- I placed the following somwhere towards the bottom of
default.hbsin my Ghost theme.
{{LIVERELOAD_SCRIPT}}
This is just an injection hook which gulp will search and replace, allowing us to alter the content as it copies the file from its source directory into the Docker container.
const LIVE_RELOAD_URL = 'http://mydevbox.local:35729/livereload.js';
const GHOST_THEME_PATH = path.join(process.cwd(), '../docker/docker-ghost-content/themes/mytheme-ghost-theme');
We need to introduce two const declarations. These could be moved to an environment variable to make this concept more portable.
- The livereload server also needs to be running while gulp is monitoring for changes.
function serve(done) {
livereload.listen();
done();
}
- I then added a
replace()command inside the handlebars section of the gulpfile.
replace('{{LIVERELOAD_SCRIPT}}', '<script async src="' + LIVE_RELOAD_URL + '"></script>'),
- And when I build the distribution zipfile for the theme, it removes the injection hook.
replace('{{LIVERELOAD_SCRIPT}}', ''),
- The actual process of copying to Docker is given its own method, encapsulating the above replace() functionality:
function copyToDockerGhost(done) {
// 1) Copy all non-HBS assets as-is (no string replacement on binary/text assets).
pump([
src([
'**',
'!node_modules', '!node_modules/**',
'!dist', '!dist/**',
'!yarn-error.log',
'!yarn.lock',
'!gulpfile.js',
'!gulpfile.mjs',
'!.git', '!.git/**',
'!*.hbs',
'!partials/**/*.hbs'
], { encoding: false }),
dest(GHOST_THEME_PATH)
], function (err) {
if (err) {
return handleError(done)(err);
}
// 2) Copy HBS templates with live-reload script replacement.
pump([
src(['*.hbs', 'partials/**/*.hbs'], { base: '.' }),
replace('{{LIVERELOAD_SCRIPT}}', '<script async src="' + LIVE_RELOAD_URL + '"></script>'),
dest(GHOST_THEME_PATH)
], handleError(done));
});
}
Now when I edit any of the handlebars template files, you’ll see the following output in a terminal session running gulp in watch mode.
[12:42:50] Using gulpfile …/Ghost/testing-ghost/mytheme-ghost-theme/gulpfile.mjs [12:42:50] Starting 'default'… [12:42:50] Starting 'css'… [12:42:50] Finished 'css' after 192 ms [12:42:50] Starting 'js'… [12:42:51] Finished 'js' after 360 ms [12:42:51] Starting 'copyToDockerGhost'… [12:42:51] Finished 'copyToDockerGhost' after 153 ms [12:42:51] Starting 'serve'… [12:42:51] Finished 'serve' after 3.21 ms [12:42:51] Starting 'cssWatcher'… [12:42:51] Starting 'jsWatcher'… [12:42:51] Starting 'hbsWatcher'… … [12:45:32] Starting 'hbs'… [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/archive.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/default.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/error-404.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/error.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/home.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/index.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/page.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/post.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/series.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/tag.hbs reloaded. [12:45:32] …/Ghost/testing-ghost/docker/docker-ghost-content/themes/mytheme-ghost-theme/partials/post-card.hbs reloaded. [12:45:32] Finished 'hbs' after 115 ms [12:45:32] Starting 'copyToDockerGhost'… [12:45:32] Finished 'copyToDockerGhost' after 140 ms
After you see these lines printed, you’ll magically see your browser reload any page being served via your Ghost container! This has been extraordinarily helpful in lowering iteration time for work on the theme.
Full Gulpfile example #
You can find the full gulp configuration file here: gulpfile.mjs, where there’s slightly more configuration to ensure things like css editing also cause a livereload event.
Just running gulp leaves you in development mode, where it watches for file changes, and livereload is active. Running gulp zip will build the theme zipfile into the dist/ directory.
Limitations #
There are limitations. If you introduce a new file, you must bounce the server in order to get it to be seen correctly. Theme files seem to be read once at launch and then not again, unless you’re uploading a new version via the admin interface. You can simply run:
docker compose restart ghostdev-web
Permissions can also be a problem. Generally speaking the ghost content will be owned by the root user, so you might need to play around with file permissions in order to be able to overwrite the data in docker/docker-ghost-content/themes/.
Maildev #
The last thing to mention is using Maildev. It’s an SMTP server which also presents a webmail interface for anything sent through it. This means that you can easily view emails that Ghost sends, like a sign in link. All you need to do is go to port 1080 on your local machine, and you’ll see this interface.
This saves you the bother of needing a fully working SMTP user for your development environment, which would send live emails over the internet. Now emails don’t need to leave the container pool of your project.