Blog
Building a Markdown Website with Caddy 2024-10-03
In this post, I'm going to explain how I built this website using Markdown documents and Caddy. This blog post is the first part of a two-part series. The second blog post is Building a Markdown Blog with Caddy.
In my first blog post, I discussed some of the reasons why one might want to build a website out of Markdown. In summary, it's much more human-friendly than HTML to read, compose, and edit on the go. Remarkably, this can be achieved with the Caddy Web Server out-of-the-box (!) using the built-in templates directive and some secret sauce that I'll reveal in this blog post.
Directory Structure
Before we dive into how all of this works, I first wanted to show the layout of the files for this website. The reason for doing this is to illustrate that most of the content really consists of Markdown documents and that there are only two files with "secret sauce" that allows all of this to work: one for loading and rendering Markdown and another to provide some functionality for this blog.
- /var/www/blakesite.com – website's root folder
- index.html – file with the secret sauce 😉 to load and render Markdown documents
- script.mjs – JavaScript to run in the web browser. Don't worry: no coffee was consumed in writing this.
- styles.css _– CSS styles to make this website look slightly better than a pig with lipstick 🐖
- favicon.ico
- images
- includes
- chroma.css – CSS to make the rendered Markdown look pretty
- header.html
- footer.html
- markdown
- index.md – the homepage (i.e.
/
) - projects.md –
/projects
- resume.md –
/resume
- blog –
/blog/
- 2024-09-06 New Website.md –
/blog/2024-09-06-new-website
, for example. The URL is just the kebab case version of the filename. - 2024-09-11 Why Databases.md
- ... any additional blog entries as Markdown documents
- index.md – has some secret sauce 😉 to run the blog
- 2024-09-06 New Website.md –
- index.md – the homepage (i.e.
Getting Started
First, you will need to install Caddy on a server with ports 80 and 443 open. This process is mostly outside the scope of this blog post, but the steps are pretty straightforward:
- Read some of the documentation
- Follow a basic tutorial and familiarize yourself with how to run a web server in general
- Install Caddy if you haven't already
- Configure your Caddyfile. More on this next!
- Start the Caddy process (i.e. with systemd)
Caddyfile
The Caddyfile, which on my machine is located at /etc/caddy/Caddyfile
,
contains the configuration for the Caddy web server. I've included a shortened
version of my Caddyfile below, and we'll examine it line-by-line.
blakesite.com {
root * /var/www/blakesite.com
encode zstd gzip
templates
file_server {
hide .*
}
# Perform canonical URI redirections not handled by file_server due to rewrite
@isDir {
not path */
file /markdown{path}/
}
redir @isDir {path}/
# Remove file suffix for Markdown files using redirect
@markdown path_regexp markdownPath ^(.+)\.md$
redir @markdown {re.markdownPath.1}
# Rewrite to index.html if path does not exist
try_files {path} /index.html
}
Okay, let's examine this file a little bit at a time…
blakesite.com {
# Caddy directives go here...
}
The first line of the Caddyfile contains the hostname of the website to be hosted. Your A and/or AAAA DNS records for your domain should point to the IP address of your web server running Caddy. Additionally, TCP port 80, TCP port 443, and UDP port 443 should be open on this host to allow web browsers to connect to Caddy. By default, Caddy will automatically fetch TLS certificates for the domain, configure HTTPS, and redirect insecure HTTP requests to the HTTPS endpoint.
root * /var/www/blakesite.com
The root
directive
sets the root path for this host. This is where you'd store all of the files for
this website, /var/www/blakesite.com
in my case. The *
is now optional in
Caddy v2.8 and above.
encode zstd gzip
This line turns on compression for large HTTP responses.
templates
file_server {
hide .*
}
The
templates
directive
allows HTTP response bodies to be executed as
Go templates. This is where a majority of
the secret sauce lies, and we will dive into this in great detail later.
The
file_server
directive
enables the static file server at the site's root path, as defined earlier. I'm
also opting to hide any files that start with a dot .
(a convention in Linux
indicating that a file should be hidden); in general, web browsers requesting
those files will get a 404 Not Found
error because Caddy's static file server will simply pretend that they do not
exist.
# Remove file suffix for Markdown files using redirect
@markdown path_regexp markdownPath ^(.+)\.md$
redir @markdown {re.markdownPath.1}
# Rewrite to index.html if path does not exist -- this is an important bit!
try_files {path} /index.html
Before we tackle the section above, we need to understand difference between URL redirects and rewrites:
- A redirect is an HTTP response (i.e.
301 or 302)
that Caddy will send to the user's web browser to tell them to try the
destination URL instead. This is useful if we want the user to see a
different URL in their address bar. For example, we may want to redirect users
from
www.example.com
toexample.com
and remove thewww.
subdomain from the web browser's address bar – it just looks nicer. - A rewrite, on the other hand, is an internal change to the URL for routing
purposes. Rather than making the user's web browser change the URL it is
requesting, we can rewrite the original URL into something else. For example,
we may want to rewrite all requests to
index.html
with a Caddyfile directive like this:rewrite * /index.html
. In this example, no matter what URL the web browser requests, we always change it internally to/index.html
. If the staticfile_server
is set up, Caddy will always serveindex.html
if it exists in the site'sroot
.
As the comments suggest, what we are trying to do in the Caddyfile snippet above is:
- For any URL path ending in
.md
, we remove the.md
suffix by issuing a 302 redirect to the URL path with the suffix removed. For example, visiting a page likehttps://blakesite.com/projects.md
will redirect tohttps://blakesite.com/projects
instead. - The
try_files
directive does a URL rewrite. If the URL path does not point to a file in the site's root, we rewrite the URL to/index.html
. In this way, we allow thefile_server
to serve all files that actually exist, but if the browser requests a URL to a file that doesn't exist,file_server
will serveindex.html
instead. This is very important, and we'll discuss this more shortly.
Lastly, we have our least significant snippet…
@isDir {
not path */
file /markdown{path}/
}
redir @isDir {path}/
In this block, we allow the web browser to request a directory without
specifying the trailing /
. This is accomplished by issuing a 302 redirect by
appending a /
to the URL path if:
- the path does not already end in a
/
and - using
/markdown
as the root directory, a sub-directory matching the URL path also exists
Note: Normally, the file_server
directive in Caddy handles this kind of
automatic URL path redirection, but sadly, we have to handle it ourselves due to
our slightly abnormal directory structure. You might see why later, but again,
this detail is relatively unimportant.
index.html
In case you haven't guessed already, a very important idea baked into our
Caddyfile configuration is that most URL paths flow to index.html
, which is
enabled by the line:
try_files {path} /index.html
Let's start with a very basic index.html
file:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="/favicon.ico" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"
/>
<link rel="stylesheet" href="/styles.css" />
<script type="module" src="/script.mjs"></script>
<title>BlakeSite</title>
</head>
<body>
{{ include "/includes/header.html" }}
<main>
<article class="markdown">
<!-- Markdown documents will go here -->
</article>
</main>
{{ include "/includes/footer.html" }}
</body>
</html>
So far this is just plain-old HTML except the lines that include header.html
and footer.html
. The part between the {{
and the }}
is the Go template
that is evaluated thanks to the
templates
directive in our Caddyfile.
Caddy exposes a function called
include
,
and that function includes the contents of the specified file and renders it
in-place (Note: it's
unescaped
HTML, so be sure you trust the included files).
For completeness, here's what includes/header.html
looks like:
<header>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="/blog/">Blog</a></li>
<li><a href="/resume">Résumé</a></li>
</ul>
</nav>
</header>
Okay, now we need to update index.html
and add our secret sauce 😉 to render
Markdown documents:
<!--
{{$pathParts := splitList "/" .OriginalReq.URL.Path -}}
{{$markdownFilename := last $pathParts | default "index" -}}
{{$markdownFilePath := printf "/markdown%s/%s.md" (initial $pathParts | join "/") $markdownFilename -}}
{{if hasPrefix "/markdown/blog/" $markdownFilePath }}
{{$markdownFilePath = "/markdown/blog/index.md" -}}
{{end -}}
{{if not (fileExists $markdownFilePath) }}
{{ httpError 404 -}}
{{end -}}
{{$markdownFile := (include $markdownFilePath | splitFrontMatter) -}}
{{$title := default $markdownFilename $markdownFile.Meta.title | stripHTML -}}
-->
<!doctype html>
<html lang="en">
<head>
<title>{{$title}} — BlakeSite</title>
<!-- Left out the rest of the <head> tag for brevity -->
</head>
<body>
{{include "/includes/header.html" | replace "\n" "\n\t\t" | trim }}
<main>
<article class="markdown {{kebabcase $title}}">
{{markdown $markdownFile.Body}}
</article>
</main>
{{include "/includes/footer.html" | replace "\n" "\n\t\t" | trim }}
</body>
</html>
So what's going on here?
-
First, we have more Go template code between the
{{
and}}
delimiters. The syntax for this is defined in the Go standard library text/template package. There are additional functions that are available thanks to the Sprig package, and finally there are other functions and variables defined by Caddy itself. The important thing to know here is that there are actually three separate documents you will need to reference. -
Recall that in order for Caddy to get to
index.html
, this URL was likely rewritten by ourtry_files
directive in the Caddyfile. So, the first thing we do is retrieve the original URL path before the URL was rewritten to/index.html
.{{$pathParts := splitList "/" .OriginalReq.URL.Path -}}
Next, we use the
splitList
function to split the path string into an array. The array is stored into a variable called$pathParts
. For example, if our original URL was something like/blog/2024-09-06-new-website
, our variable$pathParts
would contain an array of 3 elements:["", "blog", "2024-09-06-new-website"]
. -
The
last
function as defined by the Sprig package will extract the last bit of the original URL path (in our prior example,"2024-09-06-new-website"
) and stores it in a variable$markdownFilename
. If the string is empty, it defaults to"index"
. For example, if the original URL was/blog/
,$pathParts
would look like["", "blog", ""]
, and since the last element is empty,$markdownFilename
would be set to"index"
.{{$markdownFilename := last $pathParts | default "index" -}}
Also note that the
|
pipe operator takes the output fromlast $pathPaths
and inserts it as the last argument of thedefault
function. -
Next, we re-join the original path and Markdown filename to determine the path to the Markdown file on disk. Based on our file structure, we have placed all of our Markdown files in the
markdown
folder inside of our site's root path (i.e. for URL/example
, the path is/var/www/blakesite.com/markdown/example.md
– although there is no need to include the/var/www/blakesite.com
because that prefix is implied by theroot
directive).{{$markdownFilePath := printf "/markdown%s/%s.md" (initial $pathParts | join "/") $markdownFilename -}}
Feel free to read the documentation for each function: printf, initial, and join.
-
Since I have a blog and want all blog posts to render
/markdown/blog/index.md
, we do this:{{if hasPrefix "/markdown/blog/" $markdownFilePath }} {{$markdownFilePath = "/markdown/blog/index.md" -}} {{end -}}
-
Finally, we check to see if the Markdown file exists using the mostly undocumented
fileExists
function that Caddy provides. If not, we tell Caddy to respond with a 404 Not Found error. When calling thehttpError
function, template execution stops. If the file actually exists, we call theinclude
function (just as we did withheader.html
), but instead of rendering the results, we first pass it to thesplitFrontMatter
function. This will parse any front matter meta data from the beginning of your file. We then can use this data to set the title of the page, for example.{{if not (fileExists $markdownFilePath) }} {{ httpError 404 -}} {{end -}} {{$markdownFile := (include $markdownFilePath | splitFrontMatter) -}} {{$title := default $markdownFilename $markdownFile.Meta.title | stripHTML -}}
-
Finally, we render the Markdown document into our HTML template using the
markdown
function provided by Caddy. Under the hood, this uses the Goldmark library, which is CommonMark compliant. Additionally, syntax highlighting is enabled by the Chroma plugin. With a little bit of CSS, we can make these rendered Markdown documents look amazing! Please feel free to steal thechroma.css
file, which contains CSS rules for Markdown syntax highlighting using the Chroma plugin. The color theme and styling is derived from the Solarized Light theme, and it looks great!<article class="markdown {{kebabcase $title}}"> {{markdown $markdownFile.Body}} </article>
Here's a snippet of styles.css
below that applies custom styles to a rendered
Markdown document:
.blog > .drafts {
border: 3px dotted #8f7e6b;
padding: 0 0.5em;
margin: 0 4%;
}
This is possible because we set the class name of the <article>
tag using the
kebabcase title of the page. As you might see in the next blog post, I also use
kebabcase for the blog post URLs.
That's enough for now. In the next blog post, I will reveal how the blog itself works.
Conclusion
It's amazing that you can build a website out of Markdown documents without
running any additional process and without writing a ton of code. All you need
is a Caddy web server and a little bit of glue in your index.html
file!
Blog Post Index
- 2024-12-14 Typora – A Brief Review of the Best Markdown Editor
- 2024-11-06 nushell – A Shell Using Structured Data
- 2024-10-07 Building a Markdown Blog with Caddy
- 2024-10-06 Surround Sound on a Raspberry Pi 3
- 2024-10-03 Building a Markdown Website with Caddy
- 2024-09-25 My Git Configuration
- 2024-09-11 Why Databases
- 2024-09-06 New Website