Self-hosted service
envygeeks/jekyll-docker avatar
envygeeks/jekyll-docker

jekyll-docker: official Ruby based Jekyll images that keep the /srv/jekyll workdir

⛴ Docker images, and CI builders for Jekyll.

1,008 stars276 forksHCLISC

At a glance

What is it?
The jekyll/jekyll container images wrap the official Ruby image family and expose Jekyll's two development ports, with a GitHub Pages compatible variant whose versions are refreshed from a published versions file by a script. The design keeps the long standing /srv/jekyll convention, runs as root by default like the Ruby images do, and deliberately does not install operating system packages at runtime.
Who is it for?
jekyll-docker fits a team that wants a reproducible Jekyll build environment pinned to a Ruby version without installing Ruby and the gem toolchain on every developer machine or CI runner. It is the wrong tool if you need extra system packages, since the images refuse to install them at runtime and you must build your own image, and the wrong tool if you want a static site generator that is not Ruby based.
Can I use it commercially?
Yes. ISC is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 11 days ago.
What is it written in?
Mainly HCL, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Two published images and two that are gone for good

The image table is short and the retired entries are as informative as the live ones.

jekyll/jekyll is the default image with Jekyll and common community gems. jekyll/jekyll:pages is a GitHub Pages compatible image using versions from docker-bake.hcl.

Then the part that catches people out. jekyll/builder and jekyll/minimal are archived and no longer built or published. Their final release was 4.4.1, and existing latest, 4 and 4.4 tags for those images should be treated as aliases of that final release.

That last sentence is the important operational detail. The floating tags still exist in the registry, they still resolve, and they point at a build that will never be replaced. So a Dockerfile with FROM jekyll/builder:latest will keep working and will keep getting the same image, which is the right outcome for reproducibility and the wrong one if you assumed the tag meant current.

The images are built on the official Ruby image family. The README states the design intent plainly: the images keep the long-standing /srv/jekyll workdir, expose Jekyll's default development ports, and follow the official Ruby image pattern of running as root by default. That last point is deliberate inheritance rather than an oversight, and it has a direct consequence for bind mounts, which is why the usage section has a dedicated section for the --user flag.

The primary language in the repository metadata is HCL, which is the docker-bake.hcl file, and the topic tags name the stack: alpine-linux, builder, docker, docker-image, jekyll, linux, ruby and shell. The licence is ISC, which is a short permissive licence with no copyleft obligations, and the repository is not archived with the last push on 2026-10-12.

Building and serving a site, and the two ports that matter

The usage examples are the whole interface, and they are worth copying exactly rather than improvising.

Building a site in the current directory:

sh
docker run --rm \
  --volume "$PWD:/srv/jekyll" \
  jekyll/jekyll \
  jekyll build

Serving it locally:

sh
docker run --rm \
  --volume "$PWD:/srv/jekyll" \
  --publish 4000:4000 \
  --publish 35729:35729 \
  jekyll/jekyll \
  jekyll serve --host 0.0.0.0 --livereload

Two ports are published and both are needed. Port 4000 is the Jekyll development server itself, and port 35729 is the livereload websocket, which is why the image exposes 4000 and 35729 in its Dockerfile. Publishing only 4000 gives you a page that loads but never refreshes when you edit a file, and that is the most common way people conclude livereload is broken in a container.

The --host 0.0.0.0 argument is also load bearing. Jekyll's development server binds to localhost by default, and inside a container that means the published port forwards to nothing. Serving on 0.0.0.0 is what makes the port mapping work at all.

On Docker Desktop for Windows, and on other bind mount setups where host file changes do not trigger rebuilds, the README gives the fix, which is Jekyll's own polling watcher:

sh
docker run --rm \
  --volume "$PWD:/srv/jekyll" \
  --publish 4000:4000 \
  --publish 35729:35729 \
  jekyll/jekyll \
  jekyll serve --host 0.0.0.0 --livereload --force_polling

The reason is filesystem notification. A bind mount on macOS and Windows runs through a virtualised filesystem layer, and inotify events do not propagate across it, so the container never learns that you saved a file. Rather than solving that with a shared filesystem or a polling sidecar, the image leans on the flag Jekyll already ships.

The volume path is always /srv/jekyll, and it must be, because that is the WORKDIR in the Dockerfile. Mounting your site somewhere else inside the container and expecting Jekyll to find it will not work.

Bundler, file ownership and the Podman difference

Three details separate a container that works from one that quietly creates problems.

The first is Bundler. If your project has a Gemfile, the README says to run Bundler explicitly, in two steps:

sh
docker run --rm \
  --volume "$PWD:/srv/jekyll" \
  jekyll/jekyll \
  bundle install

docker run --rm \
  --volume "$PWD:/srv/jekyll" \
  jekyll/jekyll \
  bundle exec jekyll build

Note the second command uses bundle exec, so the build runs against the versions your Gemfile.lock pins rather than the gems baked into the image. The image ships Jekyll and common community gems, but a project with its own dependencies needs its own lockfile honoured.

The second is file ownership, and it is a direct consequence of the root by default design. For Linux bind mounts, the README says to use Docker's standard --user flag when you want generated files owned by your host user:

sh
docker run --rm \
  --user "$(id -u):$(id -g)" \
  --volume "$PWD:/srv/jekyll" \
  jekyll/jekyll \
  jekyll build

Without it, the _site directory is written by uid 0, and the next time you edit the site on the host you may find the files read only to you. The uid:gid form is the portable way to do this, since it works without a matching passwd entry inside the container.

The third is Podman, and it is the opposite instruction. For rootless Podman, the README says not to pass Docker's --user flag, and to run the image as-is so Podman maps container root to your host user. That is the whole difference. Rootless Podman already maps container root to the invoking user through user namespace remapping, so adding --user would double up the mapping and break the ownership rather than fix it. The image is pulled from docker.io in that example:

sh
podman run --rm \
  --volume "$PWD:/srv/jekyll" \
  docker.io/jekyll/jekyll \
  jekyll build

The rule of thumb that follows: Docker on Linux needs --user, rootless Podman needs none, and Docker Desktop on macOS and Windows does not have the problem because its bind mount layer presents files with host ownership.

The Dockerfile design: pinned versions, purged packages and a help command

The Dockerfile at the repository root is the actual specification of the image, and it is written to be parameterised by the bake file.

The base is a build argument rather than a hardcoded image:

dockerfile
ARG BASE_IMAGE=ruby:3.4
FROM ${BASE_IMAGE}

Everything else follows the same pattern. There are arguments for APT_PACKAGES, APT_PURGE_PACKAGES, COREPACK_PACKAGE_SPECS, JEKYLL_ENV which defaults to development, JEKYLL_GEM_VERSION and JEKYLL_VERSION which both default to 4.4.1, JEKYLL_GEM which defaults to jekyll, and EXTRA_GEMS which is empty by default. The version defaults line up with the 4.4.1 final release of the retired images, so the last published builder and minimal images and the current default share a version baseline.

The environment block sets COREPACK_HOME, JEKYLL_ENV and JEKYLL_VERSION, and there is a small shell snippet written to /etc/profile.d/ruby-gems-path.sh that prepends GEM_HOME/bin to the PATH when GEM_HOME is set. That exists because gems installed into a non default GEM_HOME are not on the PATH otherwise, and the official Ruby images set GEM_HOME in some configurations.

The build sequence is three steps. Operating system packages are installed with --no-install-recommends and the apt lists are removed. Corepack package managers are then prepared and enabled from COREPACK_PACKAGE_SPECS, which is how Node.js package managers get pinned per target. And Jekyll is installed with gem install --no-document, followed by any EXTRA_GEMS, and finally the APT_PURGE_PACKAGES are purged with --auto-remove along with the apt lists and the gem caches.

The last line of the Dockerfile is unusual and deliberate:

dockerfile
EXPOSE 4000 35729
WORKDIR /srv/jekyll
CMD ["jekyll", "--help"]

The default command is jekyll --help, not a server. So running the image with no arguments prints the help text and exits, which makes the image safe to pull and run without accidentally starting a listening server, and makes the required command explicit in every docker run invocation you write.

The README adds one runtime rule. The images do not install operating system packages at runtime, and if your site needs additional Debian packages you build a project-specific image, with the example being a Dockerfile FROM jekyll/jekyll that installs graphviz. The reason is stated in the README: the default image includes Node.js and pnpm, and Corepack package managers are configured per target in docker-bake.hcl.

Buildx Bake, the Pages version script and the smoke tests

Local development is driven by Docker Buildx Bake, which is why the primary language of the repository is HCL.

Building every image target is a single command:

sh
docker buildx bake

Building and loading a single target locally takes the target name and the --load flag:

sh
docker buildx bake --load jekyll
docker buildx bake --load pages

The target names match the image table, jekyll and pages, and the --load flag brings the result into the local Docker image store rather than leaving it in the build cache, which is what you need to test the image before pushing it.

The smoke test suite is run per target and per architecture:

sh
script/test jekyll amd64
script/test pages amd64

The third script refreshes the GitHub Pages versions, and this is the part of the project that answers a question every Pages user eventually asks, which is how to keep up with what GitHub is actually running. script/update downloads https://pages.github.com/versions.json and updates the Pages related Bake variables:

- PAGES_RUBY_VERSION - PAGES_JEKYLL_VERSION - GITHUB_PAGES_VERSION

So the pages image is not built from a hand maintained version list. It is regenerated from the file GitHub publishes, and the bake file is the only place those versions live. That is a good design for a compatibility target, because GitHub changes its Ruby and Jekyll versions without notice and the fix here is a script run rather than a manual edit that has to be found.

The repository layout is minimal and tells you the same story. There is a Dockerfile, a docker-bake.hcl, a .dockerignore, a .editorconfig, a script/ directory, a test/ directory, a codeowners file, a license file, and a .github/ directory. There is no application code here at all, which is the point. This repository builds images, and the source of the images is a Dockerfile plus a bake file plus a script that keeps one set of version numbers current.

The licensing is worth a note for an organisation. The licence file is ISC, which is permissive and short, and the repository is not archived with the last push on 2026-10-12. There are no GitHub releases for this project, so the image tags rather than the repository tags are the version reference, and the distinction between the two live image names and the two retired ones is the most important thing to carry away from the image table.

Editorial conclusion

jekyll-docker fits a team that wants a reproducible Jekyll build environment pinned to a Ruby version without installing Ruby and the gem toolchain on every developer machine or CI runner. It is the wrong tool if you need extra system packages, since the images refuse to install them at runtime and you must build your own image, and the wrong tool if you want a static site generator that is not Ruby based. Before adopting, decide which of the two published images you want, since the pages variant pins different Jekyll and GitHub Pages versions, and use the --user flag on Linux bind mounts so the generated site files are owned by your host user rather than root.

Frequently asked questions

How do I run Jekyll in a container with this image?

Mount your site at /srv/jekyll and pass the Jekyll command. For a build, use docker run --rm --volume "$PWD:/srv/jekyll" jekyll/jekyll jekyll build. For serving, publish both 4000 and 35729 and pass jekyll serve --host 0.0.0.0 --livereload, since the second port carries the livereload websocket.

Why do my generated site files belong to root?

The images follow the official Ruby image pattern of running as root by default, so on Linux bind mounts the build writes as uid 0. The README's fix is to add --user "$(id -u):$(id -g)" to the docker run command. With rootless Podman, do not pass that flag, because Podman already maps container root to your host user.

What is the difference between the jekyll/jekyll and jekyll/jekyll:pages images?

jekyll/jekyll is the default image with Jekyll and common community gems, while jekyll/jekyll:pages is a GitHub Pages compatible image whose versions come from docker-bake.hcl. The script/update script refreshes those Pages related variables from the published pages.github.com/versions.json file.

Can I install extra system packages in the jekyll/jekyll image at runtime?

No. The images do not install operating system packages at runtime, so you build a project-specific image starting FROM jekyll/jekyll and add what you need with apt-get, as the README's graphviz example shows.

Official sources

  1. envygeeks/jekyll-docker on GitHub
  2. Issues
  3. License: ISC
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/envygeeks-jekyll-docker.svg)](https://hysenlabs.com/projects/envygeeks-jekyll-docker)

Was this useful?