Blog

Coming Soon...

  • Building a Markdown Blog with Caddy

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.

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.

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:

  1. Read some of the documentation
  2. Follow a basic tutorial and familiarize yourself with how to run a web server in general
  3. Install Caddy if you haven't already
  4. Configure your Caddyfile. More on this next!
  5. 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:

As the comments suggest, what we are trying to do in the Caddyfile snippet above is:

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:

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}} &mdash; 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?

  1. 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.

  2. Recall that in order for Caddy to get to index.html, this URL was likely rewritten by our try_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"] .

  3. 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 from last $pathPaths and inserts it as the last argument of the default function.

  4. 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 the root directive).

    {{$markdownFilePath := printf "/markdown%s/%s.md" (initial $pathParts | join "/") $markdownFilename -}}
    

    Feel free to read the documentation for each function: printf, initial, and join.

  5. 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 -}}
    
  6. 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 the httpError function, template execution stops. If the file actually exists, we call the include function (just as we did with header.html), but instead of rendering the results, we first pass it to the splitFrontMatter 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 -}}
    
  7. 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 the chroma.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. Perhaps 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