Experience is the teacher of all things — Caesar

This page describes how I set up a pipeline to turn an Obsidian Vault into a static site on Netlify, using Quartz, Docker and GitHub Actions. Quartz is impressive (artful design, still a bit Don’t step off the happy path, but under active development).

My approach has the following advantages over the official instructions:

  1. Build step stays on Github, which has a more generous free tier that Netlify
  2. Easier to merge in upstream changes
  3. docker compose

But comes with the following caveats:

  1. My deployment has more moving parts than the official instructions
  2. There’s no guarantee npx quartz sync will work, but I prefer to perform git actions manually anyway so I didn’t even bother to wire it up
  3. You’re forced to keep your repo public (if this is a problem, replace “fork” with “clone and push to a new repo”)

If any of these are dealbreakers, official instructions


We’re going to end up with two branches, my_working_branch and _publish. When my_working_branch changes, a GitHub Action builds the static site and pushes it to _publish. When _publish changes, a Netlify deployment pipeline copies public/docroot to the live site.

We could do this with a single GitHub Action that also pushes the content to Netlify, but installing the Netlify CLI for each build would dramatically increase the wall time of the GH Action. By splitting the work between GitHub and Netlify we speed things up - important when you’re trying to keep within the free tier(s).


  1. Fork https://github.com/jackyzha0/quartz.git

  2. Check it out locally

  3. Create your own work branch, based off of the v4 branch:

    git checkout v4 git checkout -b my_working_branch

Project layout

├── .github
│   └── workflows
│       └── publish.yml
├── .gitignore
├── Dockerfile
├── README.md
├── build.sh
├── content
│   └── index.md
├── docker-compose.ci.yml
├── docker-compose.override.yml
├── docker-compose.yml
├── public
│   └── docroot
├── quartz.config.ts
├── quartz.layout.ts
└── serve.sh


Add the following to the existing .gitignore

# Obsidian local config file

workspace.json describes the Obsidian application’s local state, and will cause you no end of grief if you try to keep it sync’d across multiple Obsidian instances. Better to just ignore it.

Docker configuration


We need to copy in package*.json so we can run npm install when we set up the container - mounting directories happens after the container’s built.

8080 is where the rendered content is hosted when you --serve the site. 3001 is for a websocket callback that hot-reloads the browser preview. The ports are exposed here, but are bound to the host system later on, in docker-compose.yml. Neither port is necessary for building the site, only --serveing it.

FROM node:20-slim
# Clone the specific branch
WORKDIR /usr/src/app
COPY package*.json ./
# Install app dependencies
RUN npm install


This is run for all docker compose commands. It maps our project into the Docker image, but carves out an exception for the node_modules that we installed with npm install when we set up the container.

version: '3'
    build: .
      - .:/usr/src/app
      - /usr/src/app/node_modules


This only runs when we’re --serveing the content. Maps ports 3001 and 8080 to the outside world, which is not necessary when we’re just building.

Quartz’s output directory can’t be a Docker volume mount, because Quartz tries to rmdir it. We’re not doing that currently, but I still prefer to go one level deeper, just in case I ever need to mount public/.

version: '3'
      - "3001:3001"
      - "8080:8080"
    command: npx quartz build --serve --output=public/docroot


This only runs when we’re building the content.

version: '3'
    command: npx quartz build --output=public/docroot

Shell scripts for controlling Docker

These are just for convenience. The -d detaches from the terminal so the container runs in the background. The --build --force-recreate is because I really don’t trust Docker as far as I can fling it.

Don’t forget to chmod +x these scripts if you use them.


docker-compose up --build --force-recreate -d


docker-compose -f docker-compose.yml -f docker-compose.ci.yml up --build  --force-recreate

Obsidian setup


title: Welcome

Hello World

Once you’ve created that file, create a new Obsidian Vault in the content directory, via the “Open folder as vault” choice. Quartz starts building your site from index.md, and it’s just easier to create it manually.

You can now edit the Quartz config files as you see fit, and build and preview the site locally. You should be able to run ./serve.sh and see your site at http://localhost:8080/

GitHub Action configuration

This Action fires when my_working_branch changes. It performs the site build, and pushes the result to the _publish branch.


name: Build site
      - my_working_branch
    runs-on: ubuntu-latest
      - name: Checkout from my_working_branch branch
        uses: actions/checkout@v4
          ref: 'my_working_branch'
      - name: Build site
        run: |
          docker-compose -f docker-compose.yml -f docker-compose.ci.yml up --build --force-recreate
      - name: Commit to _publish
        run: |
          git config --global user.name 'GitHub Actions'
          git config --global user.email 'actions@github.com'
          git add public --force
          git commit -m "Result of 'Build site' Action on branch 'my_working_branch'"
          git push --force origin HEAD:_publish

Netlify configuration

On the Netlify management console, Add new site->Import an existing project->Deploy with GitHub.

Set the following properties:

Base directory: /
Publish directory: public/docroot
Production branch: _publish

That’s about it. Any Obsidian content you push to my_working_branch should turn up on your live site about three minutes later.

Tracking upstream

This is how you keep up-to-date with new versions of the Quartz application.

As a one-time thing, set a remote that points to the official Quartz repo. By convention, we call your repo origin and the remote repo upstream:

git remote add upstream https://github.com/jackyzha0/quartz.git

Now, when you want to grab the latest changes from the official repo, you do this:

git checkout v4
git pull upstream v4

At this point you can either merge the upstream changes into your working branch, or rebase your working branch on top of the changes. I prefer rebase, but merge is considered simpler:

git checkout my_working_branch
git merge v4

There’s a chance you’ll get merge conflicts. Unfortunately I can’t help unpick that, but there’s a lot of helpful material out there.

After a merge, make sure you test the site locally (./serve.sh) before publishing it.