Skip to main content

How to set up and deploy a Jekyll v4 website with Tailwind CSS and Alpine.js

– personal

In February, I posted an overview of how I redesigned my website using Jekyll v4, Tailwind CSS v4, and Alpine.js v3. As I explained back then, I used two articles by Giorgi Mezurnishvili for guidance on how to use Tailwind CSS with Jekyll, but I had to do some things differently because of changes introduced in Tailwind CSS v4, so I thought of writing an updated version of Giorgi’s guides. I’ll also explain how to set up Docker Compose for local development, a GitHub Actions workflow for deployment to GitHub Pages, and how to add Alpine.js to the stack.

Note: This guide was originally published in March, but I did a significant update on the section about adding Alpine.js to make it more convenient and flexible.

Set up Docker Compose for local development (optional)

This is optional, but I highly recommend it. Instead of having to install and configure Ruby and other dependencies directly in your system, you’ll run them encapsulated and preconfigured in a Docker image, specifically, the jvconseil/jekyll-docker image.

The first thing you’ll need is a docker-compose.yml file in your project’s root directory with the following content:

services:
  site:
    image: jvconseil/jekyll-docker:stable
    command: sh -c 'npm install && jekyll serve'
    ports:
      - "4000:4000"
    volumes:
      - .:/srv/jekyll
      - ./vendor/bundle:/usr/local/bundle
    environment:
      - JEKYLL_ENV=development
      - JEKYLL_LOG_LEVEL=info
      - NODE_ENV=development
      - TZ=America/Caracas
      - DEBUG=0

This configuration creates one service called site, makes it take the stable version of the jvconseil/jekyll-docker image as its base, defines a custom default command that will be executed when the service is run, exposes the internal port 4000 of the container as port 4000 in the host machine, defines container-host volume mappings for the Jekyll website directory and Bundler’s gems directory, and defines the environment variables. The custom command simply installs Node.js dependencies and then starts the Jekyll development server. The environment variables are pretty self-descriptive, but just in case, don’t forget to set TZ to your timezone.

The other thing you’ll need is a text file named .apk in your project’s root directory to list additional Alpine Linux packages that will be installed when the image is built and run. Specifically, you need it to install nodejs and npm to be able to use the Node.js package manager, so the file would look like this:

nodejs
npm

Like I said, you can add any other package you need. For example, I added git to be able to add Git-based gems to my Gemfile, and imagemagick to be able to use the jekyll-thumbnail-img plugin.

Finally, prevent the docker-compose.yml file from being included in your Jekyll site by adding it to the exclude list in your _config.yml:

# ...

exclude:
  - docker-compose.yml

# ...

You should know that since Jekyll v4, the items in the exclude list get added to the default exclusion list instead of replacing it, so you don’t have to manually write the default excluded items defined in the documentation. Also, you don’t need to add .apk to the exclusion list because by default Jekyll also excludes files and directories with names that start with a dot.

Use the Jekyll v4 gem

You simply have to make sure that your Gemfile lists jekyll as a dependency, not github-pages, because GitHub Pages is stuck with Jekyll v3 for the foreseeable future. Your Gemfile should look something like this:

source "https://rubygems.org"

gem "jekyll", :group => [:jekyll_plugins]
gem "webrick"

group :jekyll_plugins do
  # List Jekyll plugins here
  # ...
end

# Any other gems you need
# ...

If you use the Docker Compose setup explained before, you don’t have to manually run bundle install, because the jekyll command in the Docker Image is actually a script that uses Bundler to install the gems first and then actually run Jekyll. In any case, you can run bundle update to make sure you have the latest version of all your gems.

I want to mention that one of the changes introduced in Jekyll v4 is that Jekyll plugins listed in the group :jekyll_plugins are automatically loaded by Jekyll without needing to also list them in _config.yml, so you can remove that redundancy.

Install and configure Tailwind CSS as a PostCSS plugin on top of Jekyll

We’re going to install Tailwind CSS as a PostCSS plugin so that we can integrate it into Jekyll using the jekyll-postcss-v2 gem. That gem creates a hook in the Jekyll build process, so that it automatically regenerates the Tailwind CSS classes your website uses every time you build or every time you save changes to a file when you’re running the development server.

First, install the latest version of Tailwind CSS, its PostCSS plugin, and PostCSS itself using npm:

npm install tailwindcss @tailwindcss/postcss postcss

Next, you’ll need to create a postcss.config.js file in your project’s root directory to set up PostCSS to use @tailwindcss/postcss:

export default {
    plugins: {
        "@tailwindcss/postcss": {},
    },
};

In case you read information for previous versions of Tailwind CSS: starting from v4, @tailwindcss/postcss replaces the previous tailwindcss plugin, and it handles imports, vendor prefixing, and minification by itself, so you should no longer add postcss-import, autoprefixer, and cssnano to your PostCSS configuration. Of course, you can add other PostCSS plugins you might need; for example, I use postcss-url to move Bootstrap Icons’ font files to the appropriate folder when I build my website.

Now, add the jekyll-postcss-v2 gem to your Gemfile in the :jekyll_plugins group to install it and automatically enable it in Jekyll:

# ...

group :jekyll_plugins do
  gem "jekyll-postcss-v2"
  # ...
end

# ...

Unfortunately, there is an unresolved issue with the official version of the gem: it might fail to generate styles for all the classes you’re using because it executes PostCSS as soon as it reads your CSS file, disregarding all files that are read afterward, so you’ll have to build the site at least twice. Fortunately, I forked the repository to fix that by modifying the hook so that it executes PostCSS after the entire website has been built, thus ensuring that it detects all classes. To this date, the plugin’s author hasn’t reviewed and merged my pull request, but in the meantime you can modify your Gemfile to use the appropriate branch of my forked version of the repository like this:

# ...

group :jekyll_plugins do
  gem "jekyll-postcss-v2", :git => "https://github.com/S8A/jekyll-postcss-v2.git", :branch => "feature/change_hook_to_site_post_write"
  # ...
end

# ...

After installing and configuring those dependencies, add postcss.config.js, package.json, and package-lock.json to Jekyll’s exclusion list so that they’re not exposed on your website:

# ...

exclude:
  # ...
  - postcss.config.js
  - package.json
  - package-lock.json

# ...

Finally, to actually apply Tailwind CSS to your website, you need at least one CSS file with two required features: first, it has to be marked with Front Matter in the beginning, even if it’s empty, so that it’s treated as a “page” by Jekyll and thus processed by the jekyll-postcss-v2 plugin; and second, it has to contain at least one Tailwind CSS directive so that the @tailwindcss/postcss plugin detects it as a Tailwind CSS file. Therefore, in the simplest case, you might have a single file, let’s say main.css, with empty Front Matter in the beginning and a simple @import directive to import Tailwind CSS itself:

---
---

@import "tailwindcss";

You can add any other Tailwind CSS directive you want, whether to explicitly set source directories and files in which you want to detect Tailwind CSS classes (for example, to have different CSS files for different pages), to enable dark mode based on classes or data attributes, to use legacy plugins like Tailwind CSS Typography, to import CSS from other Node.js modules or local files, etc.

After you’ve done all of that, you can start using Tailwind CSS classes in your HTML markup. Don’t forget to link your Tailwind CSS stylesheet on the <head> of your pages or layout templates, of course.

Deploy to GitHub Pages using GitHub Actions

By default, GitHub Pages takes your main/master branch and builds it using the github-pages gem, which is locked to Jekyll v3, as mentioned above, and of course it doesn’t install Node.js dependencies. Therefore, to deploy your Jekyll v4 + Tailwind CSS setup to GitHub Pages, you’ll have to configure a custom deployment workflow using GitHub Pages, which is simpler than it sounds.

To create that workflow, simply create a YAML file named something like github-pages.yml in the .github/workflows directory of your project with the following content, and make sure to push it to GitHub:

name: Build and deploy this site to GitHub Pages

on:
  push:
    branches:
      - master

env:
  JEKYLL_ENV: production
  JEKYLL_LOG_LEVEL: info
  NODE_ENV: production
  TZ: America/Caracas

jobs:
  github-pages:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.3
          bundler-cache: true
      - name: Install apt dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y $(cat .apt)
      - name: Setup Node
        uses: actions/setup-node@v2
        with:
          node-version: '20'
      - name: Install Node dependencies
        run: npm install
      - name: Build site
        uses: limjh16/jekyll-action-ts@v2
        with:
          enable_cache: true
          custom_opts: "--verbose"
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: $
          publish_dir: ./_site

I’ll briefly explain each part of the file:

  1. Set the name of the workflow.
  2. Set the workflow to be activated whenever there is a push to the master branch. Change that to main if that’s your repository’s main branch, of course.
  3. Set the environment variables appropriately. Don’t forget to change TZ to your timezone.
  4. Define one job named github-pages that runs on the latest version of Ubuntu and executes the following steps:
  5. Check out the repository so that the workflow can access it.
  6. Set up Ruby version 3.3 (update that in the future appropriately), enabling Bundler’s cache.
  7. Install the apt dependencies specified in the .apt file.
  8. Set up Node.js version 20 (update that in the future appropriately).
  9. Install Node.js dependencies with npm install. In case you didn’t know already, you’re supposed to push package.json and package-lock.json to GitHub.
  10. Build the website with Jekyll, enabling verbose mode to have more informative logs.
  11. Deploy the generated static website to a branch named gh-pages.

The second thing you need, which I briefly mentioned above, is a text file named .apt in your project’s root directory, where you’ll list all the Ubuntu packages you’re gonna need to install. This is not the same as the .apk file mentioned earlier in the article, which is for Alpine Linux packages and will only be used by the Docker image for local development. Also, because you’re getting Node.js in another step of the workflow, you should not include nodejs and npm in the .apt file, so in the simplest case the file will be empty.

Needless to say, you don’t need to add .apt nor .github/workflows/github-pages.yml to Jekyll’s exclusion list because they’re excluded automatically.

Finally, you’ll have to modify your repository’s settings to deploy from the gh-pages branch generated by the workflow. Open your repository on GitHub.com, navigate to the Pages section of the Settings tab, and modify these two settings:

After you do that, every time you merge a pull request into your main branch or push commits directly to it, the custom workflow will run on that branch, creating or updating the gh-pages branch with your built site, and then the actual deployment workflow will run on the gh-pages branch to publish your site.

Install Alpine.js and bundle it with esbuild (optional)

This part was not at all in Giorgi’s guides, and it’s not obligatory to use it if you just want to use Tailwind CSS. I did it because I wanted to implement a dark mode toggle on my website as well as a few other small bits of interactivity, and I decided to use Alpine.js instead of vanilla JavaScript. Now I’ll explain how I did it if you want to use it too.

First, install Alpine.js and esbuild using npm:

npm install alpinejs esbuild

I figured out that I had to use something to bundle Alpine.js into my JavaScript file after I tried to use it by itself and it didn’t work. I asked Claude 3.5 Sonnet and it recommended using esbuild and gave me the command options I needed to make it work.

At first, I used esbuild by calling a custom build script I defined in package.json, and thus I had to run it manually on development and add another step to my GitHub workflow. However, now I use a custom Jekyll plugin that I made called jekyll-esbuild that automatically runs esbuild on the targeted JavaScript files when the site is built. I published the plugin on RubyGems.org, so you can simply add the jekyll-esbuild gem to your Gemfile in the :jekyll_plugins group to install it and automatically enable it in Jekyll:

# ...

group :jekyll_plugins do
  gem "jekyll-esbuild"
  # ...
end

# ...

Next, you can configure the plugin to your liking by adding an esbuild section to your _config.yml with one or more of the following settings within it: script, bundle, minify, sourcemap, files. Check out the plugin’s README for detail about the configuration options and their default values. I think the default settings are reasonable for most use cases, so you can use the plugin without adding anything to your _config.yml.

I actually use the default settings, though I set the options explicitly in my _config.yml, including explicitly listing the only JavaScript file I want to target, because I tend to agree that explicit is better than implicit, so my settings look like this:

# ...

esbuild:
  bundle: true
  minify: environment
  sourcemap: environment
  files:
    - /assets/js/main.js

# ...

Finally, to actually use Alpine.js on your website, you need the following three lines of code on the JavaScript you’re going to process with esbuild:

import Alpine from "alpinejs";

window.Alpine = Alpine;

Alpine.start();

Basically: import the Alpine.js module, add it to the window object for easy access on each page, and initialize it.

If you want to implement a dark mode toggle like I did, you’re going to need a few more lines before Alpine.start():

import Alpine from "alpinejs";

window.Alpine = Alpine;

Alpine.store("darkMode", {
    on: false,
    init() {
        if ("theme" in localStorage) {
            this.on = localStorage.theme === "dark";
        } else {
            this.on = window.matchMedia("(prefers-color-scheme: dark)").matches;
        }
    },
    toggle() {
        this.on = !this.on;
        localStorage.theme = this.on ? "dark" : "light";
    },
});

Alpine.start();

I implemented it with an Alpine.js store, its API for global state management. On the first site visit, it enables or disables dark mode based on the user’s browser setting, and it will continue to do so as long as the user doesn’t use the toggle. Once the user clicks the toggle for the first time, it will save the setting to localStorage as well so that the next time the user visits the website, it will use the value from there instead of the browser setting.