Static Sites via org-publish
Table of Contents
As seems to be an annual tradition, I've migrated this website to a new publishing system. Last year it was to Nanoc, the year before it was Sinatra, and the year before that it was sblg.
If not for pruning my posts, I'm sure half the content on the site would just be articles about the site itself.
Here's one for 2026.
1. Org Mode Projects
Org Mode is a major mode for emacs tailored towards taking notes, planning, scheduling, and literate programming. It's also an engine that can create static websites, and what this website was built with. This post will guide you through the steps to take a handful of org files and turn them into a static site.
I'll presume you know at least the basics of org-mode from this point onwards, and it would also help if you had at least a tentative grasp of elisp.
The heart and soul of a site maintained as org files is the "project". An project is (in its most minimal form), a directory containing at least one org file, added to a specific variable.
1.1. Fundamentals
The directory structure for an org-mode project can be as simple as:
> ls /home/patrick/example/ bills.org measurements.org recipies.org
And org can be informed of its existence via:
(setq org-publish-project-alist `(("example-proj" :base-directory "/home/patrick/example" :base-extension "org")))
This snippet associates the directory containing org files
with the project called example-proj. However it doesn't
do much more than that.
Our goal is to generate HTML, so to make this useful we need to extend it somewhat:
(setq org-publish-project-alist `(("example-prog" :base-directory "/home/patrick/example" :base-extension "org" :recursive nil :publishing-directory "/home/patrick/example-html" :publishing-function org-html-publish-to-html)))
The options we've added should be self-explanatory, but if not, refer to the documentation.
Once org-publish-project-alist is updated, instructing emacs to publish our
org as HTML is a call to (org-publish "example-prog" t). This gives us the following:
> ls /home/patrick/example-html/ bills.html measurements.html recipies.html
Much more useful! But still not ideal as we lack an index.html.
1.2. Sitemap
Thankfully, we can add options to our project-alist in order
to have org-mode produce a sitemap with the name index:
(setq org-publish-project-alist `(("example-prog" :base-directory "/home/patrick/example" :base-extension "org" :recursive nil :auto-sitemap t :sitemap-style list :sitemap-title "Example Project" :sitemap-filename "index.org" :publishing-directory "/home/patrick/example-html" :publishing-function org-html-publish-to-html))) ;; REMINDER -- to publish the project: (org-publish "example-prog" t)
Which when published results in the following structure (that is
perfectly suitable to place in /var/www/html/):
> ls /home/patrick/example-html/ bills.html index.html measurements.html recipies.html
There are far more options for how the sitemap is generated. The documentation explains each option well.
Of particular interest is :sitemap-style (we've used
"list" here, and for a particular reason). And if you are building a blog,
you will also want to look at :sitemap-sort-files.
1.3. Subprojects
Building a website often involves tree structures. The index branches into categories - posts grouped by year, recipies grouped by ingredients, music grouped by artist.
This creates a bit of a problem with everything under a single project.
Our sitemap relates purely to the files within /home/patrick/example-html/,
but if we were to turn recipies.org into a directory, it would currently be
ignored.
There are two solutions to this I'd proffer.
- Change
:recursive nilto:recursive t, and change:sitemap-style listinto:sitemap-style tree. This will have org-publish descend the directory tree and publish each org file it finds. The sitemap will then be structured to match the directory tree. - Create a recipies subproject that is a constituent of the example project.
The former is very simple, but the latter is incredibly flexible. We're going to look exclusively at the latter here, and we're going to start by restructuring our files and directories:
1.3.1. Example Directory Structure
> ls /home/patrick/website html/ org/ publish.el > ls /home/patrick/website/org recipies/ index.org > ls /home/patrick/website/org/recipies/ bonfire-toffee.org pannac.org
The changes we've made:
- Everything is now contained within a
/websitedirectory. - We have a publish.el file, which contains our
org-publish-project-alist. - Our sources (org files) go in
/website/org. - Our output (html files) will be generated into
/website/html/. /website/org/containts a single org file namedindex.org. This will serve as our landing page./org/recipies/contains one org file per recipie.
1.3.2. Example org-publish-project-alist
(setq org-publish-project-alist `(("index" :base-directory "~/website/org" :base-extension "org" :recursive nil :publishing-directory "~/website/html/" :publishing-function org-html-publish-to-html :with-toc nil :auto-sitemap nil) ("recipies" :base-directory "~/website/org/recipies" :base-extension "org" :recursive nil :publishing-directory "~/website/html/recipies/" :publishing-function org-html-publish-to-html :with-toc t :auto-sitemap t :sitemap-style list :sitemap-title "All Recipies" :sitemap-filename "index.org" :with-creator t) ("website" :components ("recipies" "index")))) ;; REMINDER -- to publish the project: (org-publish "website" t)
The changes from previous examples are thus:
- We have updated
:base-directoryand:publishing-directoryto reflect our filesystem changes above. - Our "index" project does not generate a table of contents or sitemap.
- We now have an additional project named "recipies" that does generate a table of contents and sitemap.
- "index" and "recipies" are components of our "website" project.
Publishing the project at this stage will give us the following directory structure:
> ls /home/patrick/website/html/ recipies/ index.html > ls /home/patrick/website/html/recipies/ bonfire-toffee.html index.html pannac.html
This, as with our previous example, is a perfectly suitable structure
for the root directory of most HTTP servers. You could very well
dump the contents of the html directory into /var/www/html and
call it a day.
However, if you've been following along, you'll have at least one large question.
How do we get from index.html to a recipie? With no sitemap on our
top-level index (our landing page), the only way to get to a recipie is
to know the filename, or manually navigate to http://my.website.tld/recipies/.
The solution is to make use of how org-mode generates sitemaps,
and to take advantage of ordering in our website project.
1.3.3. Example index.org
#+title: My Website #+author: Patrick Howdy! Below is a list of recipies: #+INCLUDE: recipies/index.org :lines "3-"
Rather than come up with some special method to generate a HTML sitemap, org-mode quite sensibly generates an org sitemap, and then converts it like any other org file.
We can use this to inline the generated sitemap. #+INCLUDE has
some very powerful options - it can even select what to include
based on contents (i.e. a specific section). In our case, we
are simply removing the first few lines so we only INCLUDE a
list of links.
As we structured our website project to refer to the "recipies" project first, by the time org-publish gets to building the "index" project, the INCLUDE can refer to a file that has already been generated.
As a reminder, that ordering looks like this in our projects-alist:
("website" :components ("recipies" "index"))
The full process looks a little like this:
- The user publishes "website"
- org-publish builds
/org/recipies/index.orglinking to all files in/org/recipies/. - and converts all org files in
/recipies/to HTML, in/html/recipies/. - and evaluates the
#+INCLUDEin/org/index.org, adding the contents of/org/recipies/index.org. - and converts
/org/index.orginto/html/index.html
Finally resulting in a landing page that links off to each recipie.
1.4. Static Content
While this is all great, and we have something that looks like a website - we aren't done yet.
Websites without images are fewer and far-between these days. So we need to consider static assets.
As always, there's more than one way to skin a cat, but I find it intuitive to extend our project further:
(setq org-publish-project-alist `(("index") ;; SNIP ("recipies") ;; SNIP ("static" :base-directory "~/website/static" :base-extension any :recursive t :publishing-directory "~/website/html/static" :publishing-function org-publish-attachment) ("website" :components ("recipies" "index" "static")))) ;; REMINDER -- to publish the project: (org-publish "website" t)
Changing the :publishing-function to org-publish-attatchment
results in org-publish performing a simple copy. I suspect that
this is not entirely performant for very large sites with many
assets - but I've yet to encounter an issue.
This does however require one small change in behaviour, as org-publish really does not like broken links.
For example, for a recipie to reference an image:
#+caption: Crème de menthe [[file:../static/images/recipies/creme-de-menthe.jpg]]
Or, if preferred:
I would advocate for the former, as captions are added to
the generated HTML, and the whole image+caption can be styled
with the figure CSS class.
1.5. Styling
Which finally leads to styling.
By default, org-publish does add minimal styling. But if you're like me, you'll want to change it.
This can probably be done in a nicer way, but my method of approach is to inject a link to a stylesheet in the static directory.
For example:
`(("index" :base-directory "~/website/org" :base-extension "org" :recursive nil :publishing-directory "~/website/html/" :publishing-function org-html-publish-to-html :with-toc nil :auto-sitemap nil :html-head-include-default-style nil :html-head "<link rel=\"stylesheet\" href=\"../static/stylesheet.css\" type=\"text/css\"/>")
Finding the classes org-mode specifies for HTML elements is best done using web inspector tools in your browser.
2. Deploying
Here's our final version of /website/publish.el:
(setq org-publish-project-alist `(("index" :base-directory "~/website/org" :base-extension "org" :recursive nil :publishing-directory "~/website/html/" :publishing-function org-html-publish-to-html :with-toc nil :auto-sitemap nil :with-creator t :html-head-include-default-style nil :html-head "<link rel=\"stylesheet\" href=\"../static/stylesheet.css\" type=\"text/css\"/>") ("recipies" :base-directory "~/website/org/recipies" :base-extension "org" :recursive nil :publishing-directory "~/website/html/recipies/" :publishing-function org-html-publish-to-html :with-toc t :auto-sitemap t :sitemap-style list :sitemap-title "All Recipies" :sitemap-filename "index.org" :with-creator t :html-head-include-default-style nil :html-head "<link rel=\"stylesheet\" href=\"../static/stylesheet.css\" type=\"text/css\"/>") ("static" :base-directory "~/website/static" :base-extension any :recursive t :publishing-directory "~/website/html/static" :publishing-function org-publish-attachment) ("website" :components ("recipies" "index" "static")))) ;; Generate (org-publish "website" t) ;; Copy artefacts to our webserver's root (shell-command "rsync -avP html /var/www/")
As elisp can call shell commands, it's easy enough to have it rsync the resulting html directory to our webserver's root.
Files that haven't changed will be ignored (static assets), and presuming there's no caching involved, the changes should be immediately visible to clients.
You can, of course, change the shell command to copy to a remote host (which is why I'm using rsync rather than elisp-native copying).
Initial deployment can be done with M-x eval-buffer, and successive
deployments can be done the same way, or by C-x C-e on the lines
that do the work.
You can also wrap both lines in a (progn to run them together.
3. Closing Notes
There's many ways to do many things, and there are many things I haven't covered in this little walkthrough.
Personally, I'd like to have the sitemap generating functions org-publish calls extract the date from each page and prefix the resulting link text with it.
I'd also like to tidy up my own project-alist to use the tree style for post years, rather than one-subproject-per-year.
There's maybe a better way to do CSS. I'd be shocked if it wasn't possible to override HTML styling in a cleaner way.
I'd like to set up an RSS feed. That will possibly be its own post.
Lastly, I'd like to containerise it. In my case, it's already
half in a container (which mounts the filesystem's /var/www.
But there's no great reason I couldn't be running publish.el
inside a container as part of an OCI build.