My Little Corner of the Net

Setting Up a Mail Server with Chasquid and Dovecot – Part 1 The Servers

Conventional wisdom says you shouldn’t try to run your own mail server.  It’s difficult: as the percentage of email that is unwanted continues to grow, it becomes harder and harder to ensure that your legitimate messages are delivered.  It’s difficult to build a reputation as a legitimate sender, and the tools needed to do that are always changing.  You have to keep an eye on blacklists, and you need to be constantly vigilant about your server’s security to ensure that no else can hijack it to send spam…and that’s just for getting messages you send delivered.  On the receiving end there’s spam filtering, ensuring that incoming messages end up in the right mailbox, TLS certificate management…and the list goes on.

I guess I’m not conventional (or, perhaps I’m just not wise).  I’ve been running my own mail servers for the better part of 20 years now and it’s been working for me.  Of course, until now, I had never taken the time to really understand my mail servers because I was just using the ones installed with the various web hosting control panels that I’ve used over the years.  All of the control panels I’ve used, however, are built around the LAMP stack and, as my hosting needs have changed, with more things running in Docker or being built in other languages, like Go, I’ve kind of outgrown the panels.  I needed to find a new email solution and, after checking out several possible solutions, I settled on running my own servers using Chasquid and Dovecot.  I’ve been running this setup for a few months now and, so far, it is working well for me.

This article will be a tutorial on how I set up my servers for my specific needs.  I hope you find it useful, but honestly, like a lot of my tutorials, I’m writing it more for me to reference the next time I have to set it up again.  There are definitely things in here that I would do differently if the circumstances were different and there are probably things that won’t make sense in other situations.  The tutorial is built on these assumptions:

  • I’m using Debian Linux on a VPS with a large hosting provider.  At the time of this writing, I’m on Debian Bookworm (12.12).  The process will probably be similar for other Linux distros, but some paths and commands may be different.  You probably won’t have much luck if you try to run this from a server on your home network as ISPs tend to block SMTP ports on residential networks and receiving hosts tend to reject mail coming from residential networks, both in the name of fighting spam.  Server specs shouldn’t matter too much if your email traffic is light to moderate.
  • Both the SMTP server (Chasquid) and POP/IMAP server (Dovecot) will be running on the same machine.
  • Each domain will have a single hostname (mail.example.com) that will be used for both sending and receiving mail.
  • Users are required to use TLS when connecting to the servers to send or receive mail.  Both Chasquid and Dovecot will be configured with TLS certificates for each domain that they host.
  • I’m using a third-party service (MXGuarddog) for spam filtering, so I am not installing SpamAssassin, ClamAV, Greylistd or any other spam/virus filtering or prevention tools on my server.  Chasquid does, however, support those three aforementioned tools.
  • Since I am using MXGuarddog, I’m using an arbitrary port number for incoming SMTP connections.  MXGuarddog knows the port I’m using and routes the mail it processes there instead of to port 25, but if spammers try to sneak around the protections I have in place, they won’t be able to find the server.

The setup process isn’t difficult, but it is lengthy, so I’ve decided to break up the tutorial into three parts: this article will focus on getting the services installed and running; part two will look at configuring domains and adding email accounts, including setting up DKIM signing and individual TLS certificates, and part three will cover the extra things that might be important, including aliases, certificate renewals, send-only accounts, and how Chasquid can interface with other services, such as spam filters.

Step 1: Installing Dovecot

If you’re familiar with Linux mail servers, you’ve probably heard of Dovecot.  Taking it’s name from a house for pigeons or doves (appropriate as pigeons have traditionally been used as messengers), it is currently one of the most popular POP3 and IMAP4 mail servers for Linux.

Dovecot is a complex piece of software that can do a lot of different things.  For me, I only need a POP/IMAP server for delivering mail to my email clients.  I’m also running Dovecot’s LMTP (local mail transfer protocol) server, which is what Chasquid will use to deliver incoming messages to their respective mailboxes.

To get started we’ll first make sure our apt package listing is up to date:

sudo apt update

Then we’ll install the necessary Dovecot packages.  This command will install a bunch of additional dependencies as well:

sudo apt install dovecot-imapd dovecot-pop3d dovecot-lmtpd

Now we need to configure Dovecot.  Dovecot comes with an elaborate, multi-file configuration that tries to address every possible configuration option.  I found that it was much easier to replace that setup with a custom configuration than to try overriding the original files.

First, we’ll rename the old dovecot.conf file, just in case we ever need to go back and check something in it:

sudo mv /etc/dovecot/dovecot.conf /etc/dovecot/doevcot.conf.bak

Then we’ll create a new dovecot.conf file with our custom configuration with this command:

cat <<EOF | sudo tee /etc/dovecot/dovecot.conf
#
# Logging
#
log_path = /var/log/dovecot/dovecot.log

#
# Email storage
#

# Store emails in /var/vmail/domain/user
mail_home = /var/vmail/%d/%n

# Store mailboxes in maildir format (file per message).
mail_location = maildir:~/Maildir

# User and group used to store and access mailboxes.
mail_uid = dovecot
mail_gid = dovecot

# As we're using virtual mailboxes, the system user will be "dovecot", which
# has uid in the 100-500 range. By default using uids <500 is blocked, so we
# need to explicitly lower the value to allow storage of mail as "dovecot".
first_valid_uid = 100
first_valid_gid = 100

#
# Authentication
#

# For convenience, place all mail account config together in the
# chasquid domain directory. Since chasquid uses "users" for it's
# user directory, an extra file named "passwd" shouldn't cause conflicts.

auth_mechanisms = plain
passdb {
    driver = passwd-file
    args = scheme=CRYPT username_format=%u /etc/chasquid/domains/%d/passwd
}
userdb {
    driver = passwd-file
    args = username_format=%u /etc/chasquid/domains/%d/passwd
}

#
# TLS
#

# TLS is mandatory.
# Add a conf file for each domain with paths to TLS certs
# in /etc/dovecot/domains
ssl = required
!include_try /etc/dovecot/domains/*.conf

# Only allow TLS 1.2 and up.
ssl_min_protocol = TLSv1.2

#
# Protocols
#
protocols = lmtp imap pop3

#
# IMAP
#
service imap-login {
    inet_listener imap {
        # Disable plain text IMAP, just in case.
        port = 0
    }
    inet_listener imaps {
        port = 993
        ssl = yes
    }
}

service imap {
}

#
# POP3
#
service pop3-login {
    inet_listener pop3 {
        # Disable plain text POP3, just in case.
        port = 0
    }
    inet_listener pop3s {
        port = 995
        ssl = yes
    }
}

service pop3 {
}

#
# Internal services
#
service auth {
    unix_listener auth-userdb {
    }

    # Grant chasquid access to request user authentication.
    unix_listener auth-chasquid-userdb {
        mode = 0660
        user = chasquid
    }
    unix_listener auth-chasquid-client {
        mode = 0660
        user = chasquid
    }
}
service auth-worker {
}
dict {
}
service lmtp {
    # This is used by mda-lmtp.
    unix_listener lmtp {
    }
}

#
# Default Folders
#
namespace inbox {
    type = private
    separator = .
    inbox = yes
    mailbox Drafts {
        special_use = \Drafts
        auto = subscribe
    }

    mailbox Junk {
        special_use = \Junk
        auto = create
    }

    mailbox "Junk E-mail" {
        special_use = \Junk
        auto = no
    }

    mailbox spam {
        special_use = \Junk
        auto = no
    }

    mailbox Spam {
        special_use = \Junk
        auto = no
    }

    mailbox Trash {
        special_use = \Trash
        auto = subscribe
    }

    mailbox TRASH {
        special_use = \Trash
        auto = no
    }

    mailbox "Deleted Items" {
        special_use = \Trash
        auto = no
    }

    mailbox Sent {
        special_use = \Sent
        auto = subscribe
    }

    mailbox "Sent Mail" {
        special_use = \Sent
        auto = no
    }

    mailbox "Sent Messages" {
        special_use = \Sent
        auto = no
    }

    mailbox "Sent Items" {
        special_use = \Sent
        auto = no
    }

    mailbox sent-mail {
        special_use = \Sent
        auto = no
    }

    Archive {
        special_use = \Archive
        auto = create
    }

    mailbox Archives {
        special_use = \Archive
        auto = no
    }
}
EOF

This configuration does a bunch of things:

  • It sets up logging, to help with debugging.
  • It specifies that we’re using virtual accounts (accounts that are not tied to Linux user accounts) and sets the user that will own those accounts.
  • It sets where mailboxes will be stored (by domain and within /var/vmail) and how they’ll be set up (using Maildir format, for one file per message).  It also specifies how user accounts are defined and where passwords are stored (I’m storing them in per-domain passwd files alongside the Chasquid domain configurations, to keep all user-related configs together.)
  • It defines our TLS requirements and specifies where to find domain-specific TLS certs (which we’ll cover more in part 2).
  • It specifies which protocols Dovecot will handle and configures the specifics of those.  Most notably, we’re disabling the older non-encrypted versions of the POP3 and IMAP protocols and requiring the use of TLS to fetch mail for better security.
  • It establishes an interface that Chasquid can use to authenticate users so that they can use a single password to both send and receive mail.
  • And it defines a what a default mailbox should look like when a user first accesses it.  As part of this, it aliases commonly used folder names together so that, for example, all sent mail will stay together across different email clients, even if one uses “Sent,” another uses “Sent Items,” and a third uses “Sent Mail.”

We also need to set up a couple of other directories.  First, we’ll create /etc/dovecot/domains where we’ll add a config file for each domain we host.  This will contain paths to the domain’s TLS certificates so that users can access their mailboxes using mail.example.com rather than myserver.example.com and not get certificate warnings.  Then we’ll add directories for dovecot to write it’s log files and for it to store the mail it receives.  Finally, we’ll set ownership on the mailbox directory to the dovenull user.

sudo mkdir -p /etc/dovecot/domains
sudo mkdir -p /var/log/dovecot
sudo mkdir -p /var/vmail
sudo chown dovenull.dovenull /var/vmail

Finally, we’ll need to restart dovecot so that it will start to use the new configuration:

sudo systemctl restart dovecot

If all goes well, Dovecot is configured and running.  Now it’s time to install Chasquid.

Installing Chasquid

I’ll admit I had not heard of Chasquid until I started researching this project, but it looked interesting, so I decided to give it a try and I’ve been happy.  Chasquid is a security-first MTA (mail transfer agent) that mostly works out of the box.

Chasquid takes its name from the Chasquis, a relay team of highly trained messengers that delivered oral messages throughout the Inca empire.  It has several features that I liked, such as:

  • It does not support features like open relaying, that are often lead to improper, insecure mailserver configurations.
  • It requires TLS for users submitting outgoing messages.
  • It can handle all of the standard sender validation techniques, including SPF checking and DKIM signing and verification.
  • It keeps track of the domains it receives incoming messages from and will reject connections if the domain’s sending server had previously connected with TLS but later tries to use plain text.  This helps prevent spammers from spoofing legitimate servers or using mailware to send messages.

Chasquid also supports all of the features you expect from an SMTP server:

  • The ability to scan for spam or viruses using third party tools like SpamAssassin or ClamAV, as well as greylisting.
  • Email aliases, including aliases that “pipe” to commands for special processing.
  • Suffix dropping (i.e. the ability to use user+somethingextra@example.com and have it be delivered to user@example.com automatically).

Chasquid is available as a Debian package, so we can install it like any other package:

sudo apt install chasquid

Then we can configure it with this command:

cat <<EOF | sudo tee -a /etc/chasquid/chasquid.conf

# Deliver email via lmtp to dovecot.
mail_delivery_agent_bin: "/usr/bin/mda-lmtp"
mail_delivery_agent_args: "--addr"
mail_delivery_agent_args: "/run/dovecot/lmtp"
mail_delivery_agent_args: "-f"
mail_delivery_agent_args: "%from%"
mail_delivery_agent_args: "-d"
mail_delivery_agent_args: "%to%"

# Use dovecot authentication.
dovecot_auth: true

# Log to file
mail_log_path: "/var/log/chasquid/chasquid.log"
EOF

Chasquid uses “sane defaults,” so there isn’t much configuration required.  The main thing we do here is tell Chasquid how to access Dovecot’s LMTP service to deliver mail.  We also set up logging and tell Chasquid to use Dovecot authentication to validate users trying to send mail against their Dovecot accounts.  All that’s required to do this is `dovecot_auth: true`, as Chasquid will look in the standard locations where most Linux distributions place this socket when it tries to connect.  If you’re using an uncommon Linux distro or a non-starndard Dovecot installation, you might have to make adjustments.

As I noted in the intro, I’m using the third-party provider, MXGuarddog, as my spam filter.  MXGuarddog serves as the mail exchanger (MX) for my domains.  Sending MTAs forward all mail to their servers, they filter it, and forward non-spam on to Chasquid.  Since there’s nothing stopping a spammer from ignoring my MX records and just trying to connect directly to any of my domains’ known host names, like mail.example.com, I use an arbitrary port number for server-to-server SMTP:

cat <<EOF | sudo tee -a /etc/chasquid/chasquid.conf

# since we're using MXGuardDog as our MX, listen on a non-standard
# port so that non-compliant spammers won't find us
smtp_address: ":20005"
EOF

Of course, it’s even better to change the port number to something completely arbitrary, like 59731 or 20956.  Once you’ve done that, you can set your server’s port number in MXGuarddog’s settings, and they’ll forward mail to that port instead of port 25.  As an alternative, you could set your server’s firewall to only allow traffic in from MXGuarddog’s published IP addresses.

I’ve been using MXGuarddog for years and I’ve found that their service works much better than any SpamAssassin config I’ve ever tried, and at 25 cents per email account per month, it’s totally worth it.  My only complaint with MXGuarddog is that they tend to be a little too aggressive:  even with my settings at their most permissive levels, they still block a lot of mail I want, especially from mailing lists, so it’s important to keep an eye on your spam reports and whitelist anything important that gets flagged.

If you choose not to use an outside spam filter and choose instead to use SpamAssassin, ClamAV, and/or Greylistd to control spam, there’s no further configuration needed for Chasquid.  Chasquid runs a hook script for every message it processes and, if it finds any of those programs installed on the server, it will call them during processing.

That’s all that’s needed to configure Chasquid, so we can give it a restart to be ready to start sending and receiving mail:

systemctl restart chasquid

Setting up a firewall

It’s always good to run a firewall on your server as an extra layer of defense against attacks.  If you already have a firewall configured, you’ll want to make sure that you open the following ports in it:

  • Dovecot: 993 (secure IMAP), 995 (secure POP3)
  • Chasquid: 587 (secure SMTP with STARTTLS), 465 (secure SMTP), and either 25 or whatever alternate port you chose for incoming SMTP above

You should not open  ports 143 (non-encrypted IMAP) or 110 (non-encrypted POP3), as these protocols pass credentials and message contents in the clear, and we specifically turned them off in the Dovecot configuration.

If you don’t have a firewall on your server already, I personally like to use ufw (Uncomplicated Firewall) for its ease of use.  To install ufw run:

sudo apt install ufw

The set up your configuration using the ufw command.

First, make sure you open up SSH (port 22), since that’s most likely what you’re using to access the server right now, and you don’t want to block yourself:

sudo ufw allow 22/tcp

Then allow the ports for Dovecot and Chasquid (remember to change 25 to your random SMTP port if you changed it):

sudo ufw allow 993,995/tcp
sudo ufw allow 25,465,587/tcp

And finally, enable the firewall.  This should work without dropping your current SSH session.

sudo ufw enable

Wrapping up

So far we’ve installed Dovecot and Chasquid using a configuration that’s secure and that should make sense for most users.  In part two, we’ll look at how to add email domains to the server, how to set up user email accounts, and we’ll explore what needs to be done to ensure the messages we send get delivered properly.

Note: I may receive service credits from MXGuarddog for mentioning them here. Regardless, they’ve been a great provider who I’ve been paying a modest sum to manage my spam for years, and my recommendation is genuine.

Am I an Avid Reader Yet?

At the beginning of 2024, I gave myself a goal to read more. As a kid, I loved to read, but somewhere along the line, life happened, and aside from an occasional book here and there, I hadn’t really been reading much for several years. I decided to change that.

I started off by making a list of books that I remembered having to read in high school and college. I thought it would be fun, or at least interesting, to read them again with a fresh set of eyes. Back then, my perspective was to read what I needed to know to be successful in class, not so much to enjoy the story. Some books I liked, some books I didn’t, but I wanted to give them a fresh look with my now more experienced eyes. What would I get from them twenty or more years later that I missed the first time? Would my expanded world view make me look at the stories differently than I did as a kid? Would I find some new meaning in a book that I hated as a teen?

It started on a Saturday afternoon with a digital copy of The Great Gatsby that I checked out from the New York Public Library’s app. To my surprise, I was finished with it by the end of the day.

I had no idea how many books I could realistically finish, so I set my goal relatively low: five books by the end of the year. After Gatsby, I went on to read several other American classics such as A Separate Peace, To Kill A Mockingbird, The Red Badge of Courage, and Tortilla Flat. By July I had reached my goal.

On Prime Day, I decided to buy a Kindle. Until then, I was mostly reading on an older Samsung Galaxy Tab tablet. It worked well, as long as I wasn’t trying to read outside in the sun. I figured the Kindle would be more versatile.

The Kindle came with a trial subscription to Kindle Unlimited. My initial impression of the service was that it didn’t have a lot of what I wanted to read. Still, I figured I’d find things that looked interesting for the three months that I had the trial and then I’d cancel. Of course, I still keep finding things I want to read, so I still have the subscription which I am now paying for (but I guess I’m using it enough to make it worthwhile).

By the end of 2024, I had finished 13 books, with another two in progress. I decided that a modest increase to 15 books would be a good goal for 2025, and I kept chugging away.

As of today, April 22, 2025, I have finished my 15th book of the year, Dr. Suess Goes to War: The World War II Editorial Cartoons of Theodore Suess Geisel, with still over eight moths left to go in the year.

I usually have two books going at once, generally one fiction and one nonfiction at any given time, and I try to dedicate about and hour a day to reading. Some days that doesn’t happen and I only end up reading a few pages, some days I get really into it and read for much longer. As of today, Amazon is reporting that I’ve read for 150 days in a row!

So what do you think? Am I qualified to call myself an avid reader yet?

Expanding the File System on a Le Potato with Raspberry Pi OS

I have a Libre Computer Le Potato single board computer that I bought during the pandemic when Raspberry Pi boards were pretty much impossible to get. It has turned out to be a great little machine that’s been so reliable that a year or so ago I decided to move some of my most important home lab services, including my smart home software stack (Home Assistant, Node-RED, etc.), to it.

Libre Computer offers a version of the Raspberry Pi OS Bullseye (which they still refer to as Raspbian) that has been customized for the Le Potato’s hardware on the boards’s downloads page. For consistency with my actual Pi devices, I’m using this image on my board, though I’ve manually upgraded it to Bookworm. For the most part, everything works exactly as it would on a Pi. There are a few small differences, such as the mount points of the filesystems, but nothing that you’d notice during normal, everyday operations.

Lately, the machine been acting a little wonky–web requests would, for example, take forever to load if something else was running at the same time. Upon investigation, I found that my 32Gb MicroSD card was nearly full. No problem—just get a bigger card, clone the current one onto it, and then use raspi-config to resize the partition. Easy peasy.

I got a new 128Gb MicroSD card and proceeded to do just that. I shut down the Le Potato, ejected the card, and then used Balena Etcher on my MacBook to copy the contents of the old card to the new one (there’s any number of ways I could have done this, including the Linux dd command, but Etcher has become my preferred way to prepare SD cards for my various little computers). When It was done, I put the new card in the Le Potato and started it back up. As expected, the machine booted and everything came up, but the drive was still full because copying the partition doesn’t resize it.

Next, I ran sudo raspi-config, went to “Advanced Options,” and chose “Expand Filesystem.” After going through the prompts and rebooting, I ran the df -h command to check the free space available on the machine’s disk and, to my surprise, it was still showing as full.

Filesystem     Size  Used Avail Use% Mounted on
/dev/mmcblk1p2  30G   28G    2G  97% /

Note, to be concise, I’m only showing the affected drive’s stats. When you run the command you’ll likely see a several lines for things like in-memory file systems that the operating system creates when it runs.

Thinking that the resize didn’t take for some reason, I ran sudo fdisk -l to list the partition table, and I got this:

Device         Boot  Start       End   Sectors   Size Id Type
/dev/mmcblk1p1        8192    532479    524288   256M  c W95 FAT32 (LBA)
/dev/mmcblk1p2      532480 249737215 249204736 118.8G 83 Linux

Clearly the partition has been resized, but the drive wasn’t seeing it. Why not? I was perplexed.

After a bit of pondering, I decided to check what type of file system the drive was using. To do this, I ran df -hT. This time, I got the following output:

Filesystem     Type      Size  Used Avail Use% Mounted on
/dev/mmcblk1p2 btrfs      30G   28G    2G  97% /

Butter FS! All of my actual Pi’s are using the Ext4 file system, which will automatically use the full partition when it is expanded, but my Le Potato is using btrfs, the BTree File System. btrfs is a newer file system that has some nice features, including the ability to span multiple physical drives, data integrity features similar to RAID, and the ability to make snapshots of the drive’s state. I chose to use btrfs when I built my NAS because it gave me RAID-like redundancy across the initial two drives that I added, but would also let me use different size drives in the future, when I need to add more capacity, without losing any space the way I would with RAID.

Because of the way btrfs works, another step is needed:

btrfs filesystem resize max /

This tells btrfs to resize the file system mounted at / (the root files system) using all of the space available to it which, in this case, is the remaining portion of the partition created by raspi-config.

The fact that this can be done (and has to be done) on the running filesystem always seems weird to me as I’m used to having to unmount drives before doing any kind of maintenance on them, but because btrfs can span multiple disks, the file system has to be mounted.

While it technically isn’t necessary, I did one more reboot after resizing, just to make sure everything got updated:

sudo shutdown -r now

Once back up, I ran df -hT again and now I’m seeing this:

Filesystem     Type      Size  Used Avail Use% Mounted on
/dev/mmcblk1p2 btrfs     119G   28G   91G  24% /

Much better. And more importantly, the machine appears to be stable once again.

I don’t know how popular btrfs is with other Raspberry Pi “clones,” but if you attempt to install a larger SD card and don’t get the results you’re expecting, be sure to check what file system is being used. There may be extra steps necessary.

Simple Image Galleries With Eleventy

As I continue converting my largest non-work site from Jekyll to Eleventy, I keep coming across things that I did in Jekyll that no longer work in Eleventy.  One of these is image galleries.

Jekyll and Eleventy have a fundamentally different approach to how they handle files.  Jekyll splits all of the files in the project folder into two types, based on whether or not they contain front matter.  Files with front matter are transformed and the result is saved to the site folder.  Files without front matter (which includes all images, PDFs, JavaScript, CSS, etc.) are simply copied to the site.  The latter, which Jekyll refers to as “static files” are placed into a static_files collection which can be accessed in templates.  I was able to use this collection to make simple photo galleries.

First, I created a gallery.html layout that looked something like this:

{{ content }}

<div class="gallery">
  {% for image in site.static_files %}
    {% if image.path contains page.gallerydir %}
      <div class="gallery-image"><a href="{{ image.path }}" class="gallery-link"><img src="{{ image.path }}"></a></div>
    {% endif %}
  {% endfor %}
</div>

Then, for each gallery page, I’d add a markdown file with a gallerydir variable in my front matter set to the path of the directory containing my gallery images:

---
title: Big Event Photos
layout: gallery
gallerypath: events/images/big-event
---

Check out some photos from our Big Event!

When the page is processed, the template code loops through the entire static_files collection, checks whether the path of each file falls within the gallerydir, and if so, links to it in the output.  I use a lightbox script (GLightbox, in this specific case) to allow the user to browse the images in a pleasing way.

Eleventy doesn’t have this concept of static files.  Eleventy only processes the types of files you tell it to look at and ignores everything else.  If you want Eleventy to copy static files, you have to tell it to do so by using eleventyConfig.passthroughFileCopy() or something similar.  While this will get the files into your site, they won’t be automatically added to any collections.

To build the list of gallery images, and keep them separate from the rest of the site’s images, I moved all of the gallery images into a “galleries” directory.  Within that I created subdirectories of images for each gallery.  Then I used the NodeJS package fast-glob to find those files.

First, fast-glob has to be installed:

npm install --save-dev fast-glob

Then, it needs to be imported within .eleventy.js:

const fastglob = require("fast-glob")

And then we call it from inside the module.exports routine within .eleventy.js to build a list of our gallery images:

const galleries = fastglob.sync(["**/galleries/*/*.*", "!_site"])

This sucks the paths of any file that has the parent directory structure of galleries/{some gallery name} into an array. (The second parameter, !_site, tells fast-glob to ignore any file paths that are already copied to the _site directory; flast-glob doesn’t understand the Eleventy file structure, so it doesn’t know to ignore _site.)  To actually use it, we need to create a new Eleventy collection. To do that, we also add this to module.exports:

eleventyConfig.addCollection("galleries", function (collection) {
    let items = galleries.map((x) => {
        let paths = x.split("/")
        return {
            gallery: parts[parts.length - 2],
            path: x,
            name: [parts.length -1]
        }
    })
    return items
})

This takes the list of image paths and turns it into a set of objects with three properties: gallery (the name of the gallery the image is within, pulled from the last directory name in the path), path (the original path of the image), and name (the filename of the image, which I’m not actually using right now, but I figured my be useful to know in the future).  This list of objects is used to populate a new “galleries” collection in Eleventy.

With this new collection, I can update my gallery layout to look more like this:

{{ content }}

<div class="gallery">
    {% assign images = collections.galleries | where: "gallery", gallery %}
    {% for images in images %}
        <div class="gallery-image"><a href="{{ image.path }}" class="gallery-link"><img src="{{ image.path }}"></a></div>
    {% endfor %}
</div>

And in my page’s front matter, I replace gallerypath with gallery, and assign it the name of the gallery (i.e. the directory name within one of my site’s “galleries” directories) I want to show:

---
title: Big Event Photos
layout: gallery
gallery: big-event
---

Check out some photos from our Big Event.

it’s important to note that fast-glob only returns a list of files that match the pattern, it does not copy them to the site automatically.  In my case, an existing passthroughFileCopy() for all JPEG images does the trick,  but we could also update the map function inside the addCollection() to handle this if we wanted.  As a future extension to this concept, I may look at using Eleventy’s Image plugin to automatically resize my images to ideal dimensions, but in my current use case, all of my images have already been manually resized.

So that’s how it did it.  This method gets me to feature parity on the Eleventy site, it still needs some work.  As it stands now, neither the Jekyll or the Eleventy solution is accessible.  I need a way to add additional information, like alt text for the images, to the galleries.  The obvious solution is probably to add a CSV file to the site’s _data directory to store this information, but then I could just loop through that instead of using the file glob, so maybe this whole approach isn’t really needed at all.  We shall see.

Recreating Jekyll’s _drafts Directory in Eleventy

I’m in the process of converting a couple of sites that I built a few years ago using Jekyll to use Eleventy instead. Both tools are static site generators that work very similarly, but Eleventy gives me more flexibility and, given that it’s based in JavaScript—a language I use daily—rather than Ruby—a language I know almost nothing about—Eleventy is much easier for me to extend and customize to my needs.

Jekyll has a unique feature that Eleventy does not: the drafts folder. In Jekyll, you can add content that isn’t ready for public consumption to a directory named _drafts , and when you build the site, this content will be ignored. To include the content, you add a --drafts argument to the jekyll build or jekyll serve command.

While I never really made much use of this feature, the site I’m converting now does have a drafts directory with a couple of in-progress pages. Eleventy doesn’t have the concept of draft content, so I wanted to find a workaround, at least for the time being.

Eleventy has a few ways to ignore files from being processed. First, anything inside node_modules is automatically ignored. Then, anything in .gitignore (at least by default) or .eleventyignore files gets excluded, but adding the _drafts directory to one of these would mean it would never be processed. I need a way to selectively tell Eleventy to build the draft content when I want it and ignore it when I don’t.

Fortunately, there is a simple solution: Eleventy’s ignores collection, which is automatically populated from the files above. Eleventy conveniently provides an API for adding or removing paths from the collection on a programatic basis. To make the drafts folder work, I added the following inside the module.exports in my site’s .eleventy.js file:

if(process.env.ELEVENTY_ENV === "production") {
    eleventyConfig.ignores.add("_drafts")
}

This looks for an environment variable named ELEVENTY_ENV with the value production and, if found, adds the file glob “_drafts” to the list of ignored content. This has the effect of ignoring anything located in any directory named _drafts located anywhere within the site when that environment value is present. If the ELEVENTY_ENV variable is not set, or it contains a different value, the draft content will be processed.

I’m already using ELEVENTY_ENV to manage minification and map file creation for CSS and client-side JavaScript assets, so this works well for me. In fact, I don’t really have to think much about it because I’ve incorporated it into the npm run scripts in my `package.json`:

"scripts": {
"build": "npm run clean; ELEVENTY_ENV=production npx @11ty/eleventy",
"build-test": "npx @11ty/eleventy",
"watch": "npx @11ty/eleventy --watch",
"serve": "npx @11ty/eleventy --serve",
"clean": "rm -rf _site/*"
}

This means that drafts will be excluded if I nmp run build the site, but not during development when I’m most likely using npm run serve. If I want to exclude that content during development for some reason, it’s just a matter of running ELEVENTY_ENV=production npm run serve.

<