Automatically deploy your blog via Git with Caddy and Docker

— 1351 words —

After this was written, I ended up moving to Hugo due to a lack of Asciidoctor support in Zola.

Over the years, I’ve incrementally reduce the scope of my blog. I used to use an entire rails setup with postgres, because that was the first thing I learned… Yet that’s quite the overkill for a blog with static content. I don’t even include comments anymore.

This is where Zola comes in, previously named Gutenberg. It’s a static site generator written in Rust that uses Tera for templating. It serves as a counterpart to Hugo, written in Golang. Both have a similar featureset, so I chose Zola since I use Rust and can contribute if needed.

However basically any static site generation system can work, so long as you end up with files generated to your liking.


Feel free to skip this section if you already have your own static site generation system.

One caveat: Zola minifies sass by default but not javascript. I use a Makefile to minify the js files and bundle them.

Getting started

Zola has a getting started guide. For inspiration, you can look at the source code for this very blog or different sites using Zola.

My setup

This is what my site’s directory looks like:

├── binary-data/
├── Caddyfile
├── config.toml
├── content/
├── docker-compose.yml
├── Dockerfile
├── Makefile
├── public/
├── sass/
├── static/
├── syntaxes/
├── templates/
└── themes/

binary-data is where I store all the screenshots, pdfs, and other binary data I refer to my posts. For the actual posts I upload these to an amazon S3 bucket, but I keep these as a backup, outside of git.

sass/ and static/ are pretty easy: the former gets compiled to css, the latter gets copied directly to the public/ directory during generation.

For code, themes contains the syntax highlighting theme, and syntaxes contains sublime syntax files I added because Zola doesn’t support slim syntax highlighting yet.

This leaves us content, the actual posts and pages, and templates, for how to render them. templates also contains shortcodes/, which function much like wordpress’ shortcodes.


These are all the templates I’ve made. Naturally it can get as complex as you want. I generally have one per page or page type, such as /projects or /posts.

At a minimum, you probably want a base.html to deal with the oh-so-fun SEO stuff, and a macros.html for ``dynamically'' rendering things. I use it for the navigation bar, footnotes, references, citations, and rendering links.

With child templates, you can use blocks to inject content back to the parent:

<!-- base.html -->
  <!-- constants in base.html header -->
  <meta charset="UTF-8">

  <!-- set from child -->
  <title>{% block title %}{% endblock title %}</title>
  {% block head %}{% endblock head %}

then in the child you define the block:

<!-- zola provides objects like page/section/config, see the docs -->
{% block title %}{{ page.title }} | {{ config.title }}{% endblock title %}

Thrilling stuff.

If you look at my source it can appear a tad complex now, but I just slowly added things as they came up– like the page title, then custom SEO attributes, etc.


Tera’s macro system is really useful. One of my use cases was to show the tags and categories below a post:

{% macro render_tags(tags) %}
  <div class="tags">
    {% for tag in tags %}
      {% if loop.last %}
        <a href="/tags/{{ tag | slugify }}/">{{ tag | title }}</a>
      {% else %}
        <a href="/tags/{{ tag | slugify }}/">{{ tag | title }}</a> |
      {% endif %}
    {% endfor %}
{% endmacro render_tags %}

Yes, I should probably clean them up a bit. They work good enough for now.


Shortcodes are awesome. Two main things I use them for:

  • footnotes, citations, references

  • generating boilerplate for lity.js (a lightbox lib)

<!-- img.html -->
<a href="{{url}}" data-lity data-lity-desc="{{desc}}" alt="{{desc}}">
  <img class="full" async-src="{{url}}"/>
{% if t %}
  <p class="image-desc"> {{t}} </p>
{% endif %}
<!-- fn.html -->
<a id="footnote-cite-{{num}}" href="#footnote-{{num}}">({{num}})</a>

My Korean for Programmers post uses ~5 shortcodes to high light words in different colors:

<!-- hlm.html -->
<!-- where t=text,c=css color class name-->
<span class="hl hl-middle hl-{{c}}">

Okay, okay.. Time for the real stuff.

Static Assets Repo

Now that you have your static files, commit them to a new git repo. With Zola, I use rsync to move the output from public/ to another directory–since zola build nukes it each time.

As stated earlier I keep binary files like images in a separate directory, and in the posts themselves I link to amazon s3. If you want to link to assets locally, you might need something like Git LFS from Github or or a different solution.

I keep my statically generated assets at because sourcehut doesn’t support webhooks yet.


Caddy is an awesome HTTP/2 web server. It handles SSL certs for you automatically via Lets Encrypt, and it has a git plugin which we’ll be using. The git plugin clones or updates a repo for us, so we can now push content to a git repo and have it automatically update!

Let’s create the Caddyfile:

Use a port (like :2015) for local testing instead of the actual domain! If you run Caddy with this caddyfile locally without the -disable-acme-auth, caddy will repeatedly try to authorize, quickly ratelimiting you from Let’s Encrypt! You can also use''tls off" to skip it entirely.
# Caddyfile, {
  tls [email protected]

  cache {
    default_max_age 10m

  git {
    hook /webhook {%SITE_WEBHOOK%}
    branch master
    clone_args --recurse-submodules
    pull_args --recurse-submodules
    interval 86400
    hook_type github

  root /www/public

The SITE_WEBHOOK environment variable is set in .env.

Note that a webhook is optional. In fact, all of the git directives here are optional besides the repo path itself. By default the plugin clones to the root path, /www/public in this case.

I’ve set it to pull once per day as well as listen for requests on /webhook. Right now I use github webhooks as sourcehut doesn’t seem to support webhooks yet.

If you’re running multiple containerized services you can use caddy as a proxy as well. You can see the source for’s docker script as an example. I have an http docker service that proxies to my website service, which looks like the following:

# services/http/Caddyfile,, {
  tls [email protected]

  log / stdout {combined}
  errors stderr

  proxy /webhook http://website:1111/webhook {

  proxy / http://website:1111



Lastly, we’ll run all of this inside a docker container, so we need a Dockerfile:

FROM alpine:edge
LABEL caddy_version = "1.0.0" architecture="amd64"

# Caddy
RUN adduser -S caddy

ARG plugins=http.git,http.cache
ARG version=v1.0.0

RUN apk add --no-cache --virtual .build-caddy openssh-client tar curl \
  && apk add --no-cache git \
  && curl --silent --show-error --fail --location \
  --header "Accept: application/tar+gzip, application/x-zip, application/octet-stream" -p \
  "${version}&plugins=${plugins}&license=personal&telemetry=off" \
  | tar --no-same-owner -C /usr/bin -xz caddy \
  && chmod 0755 /usr/bin/caddy \
  && apk del --purge .build-caddy

RUN /usr/bin/caddy --plugins
RUN mkdir /www \
  && chown -R caddy /www

COPY Caddyfile /etc/Caddyfile

USER caddy
ENTRYPOINT ["/usr/bin/caddy"]
CMD ["--conf", "/etc/Caddyfile", "--log", "stdout", "-agree"]

and a corresponding docker-compose file:

version: '3.7'

    restart: always
      context: .
    image: <your_dockerhub_username>/personal-site
      - "1111"
      - ".env"

I try to use alpine docker whenever possible. This image fetches a predefined Caddy version, v1.0.0, with the cache and git plugins.

We need to pass the -agree flag to agree to Let’s Encrypt’s Subscriber Agreement. Caddy will not run otherwise unless you use -disable-http-challenge (or specify http/a port), but we want HTTPS, no?

Deploying the image is just docker push once you’ve signed in via the docker cli.

…and that’s pretty much it. For your VPS, you’ll want to install docker and/or docker-compose, then run the image. If you set up a corresponding docker-compose file, you can do docker-compose pull && docker-compose up -d.

If you’re using webhooks, don’t forget to configure the webhook on github/gitlab/bitbucket/etc.

If configured correctly, you should now be able to git push your static assets and automatically have the container pull them in!

other caddy posts
Selfhosting git with Gitea, Docker, Caddy
other docker posts
Selfhosting git with Gitea, Docker, Caddy