Deploying Quartz v5 on Docker: A Complete Guide for Self-Hosted Knowledge Bases

Introduction

If you want to run a self-hosted knowledge base, deploying Quartz v5 on Docker provides a fast, batteries-included static site generator that transforms Markdown files into a website. Version 5 introduced a community plugin system, YAML-based configuration, and a completely redesigned architecture. If you run an Obsidian vault as a published knowledge base—as I do with my Inner Range technical reference wiki—hosting it yourself on a self-hosted web server on VPS with Docker gives you full control.

This guide walks through deploying Quartz v5 on Docker, from repository setup to production deployment, including the pitfalls I hit and how you can avoid them.

Quartz v5 Logo


Why Deploy Quartz v5 on Docker?

Quartz is a Node.js application. You could run npx quartz build directly on a server, but Docker offers several advantages:

  • Reproducible builds — The same Dockerfile works locally and on any VPS
  • No Node.js installation on the host — The container has everything it needs
  • Easy rollbacks — Rebuild from any Git commit
  • Nginx in the same stack — Serve static files with proper caching and URL handling
  • Plugin isolation — Community plugins install inside the container, never pollute the host

Architecture Overview

Here’s the architecture we’re building:

ComponentRole
GitHub repositorySource of truth — contains the Quartz source, your content, config, and Dockerfile
Multi-stage DockerfileStage 1: build the site (Node.js). Stage 2: serve it (Nginx Alpine)
Docker ComposeManage the container lifecycle with restart policies
VPSHostinger VPS running Docker, ports exposed via Nginx Proxy Manager

The key insight: your repository is the Quartz v5 source. You fork the upstream branch, add your content and Docker config, and that single repo becomes both your content vault and your build system.

your-repo/
  content/          ← Your Markdown pages
  quartz/           ← Quartz v5 source (forked from upstream)
  quartz.config.yaml ← Your site configuration
  Dockerfile        ← Multi-stage build
  docker-compose.yml
  nginx.conf

Step 1: Fork Quartz v5 Into Your Repository

Rather than cloning Quartz as a dependency at build time, make your repository the Quartz source itself:

# Add the official Quartz repository as upstream
git remote add upstream https://github.com/jackyzha0/quartz.git

# Fetch and branch from upstream's v5
git fetch upstream v5
git checkout -b v5 upstream/v5

# Push to your own repository
git push -u origin v5

Now your repository has the complete Quartz codebase under quartz/. Install dependencies:

npm install

Run the setup wizard to configure your site:

npx quartz create

This prompts for a template (choose obsidian if you use an Obsidian vault), a content folder, and generates quartz.config.yaml.


Step 2: Move Your Content In

Quartz v5 expects content in the content/ folder:

# Copy your Markdown files into content/
cp -r /path/to/your/vault/* content/

If migrating from Quartz v4, note that the content folder was previously named wiki/. The migration guide at quartz.jzhao.xyz/getting-started/migrating covers the full process.


Step 3: Install Community Plugins

Quartz v5 plugins are standalone Git repositories maintained under the quartz-community organization. Your quartz.config.yaml lists which plugins to enable, and the CLI installs them:

npx quartz plugin install --from-config

This reads your config, clones each plugin into .quartz/plugins/, and records the exact commits in quartz.lock.json for reproducible builds.


Step 4: The Dockerfile

Here’s the multi-stage Dockerfile I use in production:

FROM node:24-slim AS builder

RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*

WORKDIR /quartz

# Install dependencies first (cached layer)
COPY package.json package-lock.json ./
RUN npm ci

# Copy everything else
COPY . .

# Install plugins and build the site
RUN npx quartz plugin install --from-config
RUN npx quartz build

FROM nginx:alpine

COPY --from=builder /quartz/public /usr/share/nginx/html

# Symlink for image asset paths (e.g., /wiki/assets/...)
RUN ln -s . /usr/share/nginx/html/wiki

COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

Key details:

  • Stage 1 (builder): Uses node:24-slim to run npm ci, install plugins, and build the static site into /quartz/public/
  • Stage 2 (runtime): Uses the tiny nginx:alpine image — only the built HTML/CSS/JS, no Node.js, no build tools
  • The --from-config flag tells Quartz to install exactly the plugins and versions pinned in quartz.lock.json
  • The symlink (ln -s . /usr/share/nginx/html/wiki) preserves image paths that reference /wiki/assets/... from the v4 URL structure

⚠️ Important: Always use --from-config, not --latest, for production builds. --latest pulls the newest commits of all plugins, which can introduce breaking changes. --from-config uses your tested, locked versions.


Step 5: Docker Compose

A minimal docker-compose.yml:

services:
  irwiki:
    build: .
    container_name: irwiki
    restart: unless-stopped
    ports:
      - "8085:80"

The restart: unless-stopped policy ensures the container comes back up after a server reboot.


Step 6: Nginx Configuration

A simple Nginx config to serve the static site with proper caching and a custom 404 page:

server {
    listen 80;
    server_name irwiki.kamath.cloud;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri.html $uri/ =404;
    }

    # Cache static assets aggressively
    location /static/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Step 7: Deploy

With your Dockerfile, docker-compose.yml, and nginx.conf committed and pushed to your repository, deploy on the VPS:

ssh root@your-vps
cd /docker/irwiki
git pull
docker compose build --no-cache
docker compose up -d

⚠️ Always use --no-cache on the first deploy and after any plugin change. Docker caches intermediate layers. If a plugin install failed once, the cached layer will serve the broken install forever. --no-cache forces a clean rebuild.


Pitfalls I Hit (So You Don’t Have To)

1. npm Fails on Google Drive Mounts

If your repository lives on a Google Drive folder (common with Obsidian vaults), npm install and npx will fail with EBADF or EPERM errors. Cloud sync tools interfere with Node.js file operations.

Fix: Clone your repository to a local disk (e.g., C:\temp) for all npm/npx operations, then copy only the config files back to the Google Drive folder.

# Work on local disk
cd /c/temp && git clone --branch v5 /path/to/google-drive/repo wiki-build
cd wiki-build && npm ci
npx quartz plugin install --from-config
npx quartz build

# Copy changed configs back
cp quartz.config.yaml quartz.lock.json /path/to/google-drive/repo/

2. Plugin Loader Errors Are Misleading

If a community plugin fails with "declares components but failed to load them", the real error is almost certainly different from what the message says. The v5 component loader swallows the actual exception and prints this generic warning.

Fix: Read the loader source code at quartz/plugins/loader/componentLoader.ts. In my case, the loader imports the ./components subpath of the plugin, but the plugin only exposed its main . entry. Adding a ./components export to the plugin’s package.json fixed it instantly.

3. Private Plugin Repos Won’t Work in Docker

Your Docker build has no GitHub authentication. Any plugin referenced in quartz.config.yaml with a github: source must be in a public repository. For private custom plugins, use a local folder inside your repository:

- source: ./plugins/my-custom-plugin
  enabled: true

4. Docker Cache Masks Plugin Failures

If npx quartz plugin install --from-config fails during a Docker build, the error might scroll past. On the next build, Docker reuses the cached layer from before the plugin step — so even after fixing the plugin, the build appears to “work” but still serves the broken version.

Fix: Always use --no-cache after any plugin change. For routine content-only deploys, a normal cached build is fine.


Daily Workflow

For content updates (no plugin changes):

# Edit your Markdown files in content/
git add content/
git commit -m "Update hardware pinout reference"
git push

# Deploy (cached build is fine for content-only changes)
ssh root@vps "cd /docker/irwiki && git pull && docker compose up -d --build"

For plugin changes or Quartz version upgrades:

# Always use --no-cache
ssh root@vps "cd /docker/irwiki && git pull && docker compose build --no-cache && docker compose up -d"

Rollback

Your old v4 (or previous v5 commit) is preserved in Git. To roll back instantly:

ssh root@vps
cd /docker/irwiki
git checkout main          # Or any previous commit
docker compose up -d --build

The old build runs and the site is back. No database migrations, no state to unwind.


Verification

After deploying, confirm everything is healthy:

# Check the Quartz version
curl -s https://irwiki.kamath.cloud | grep -o "Quartz v[0-9.]*"

# Check a deep page
curl -s -o /dev/null -w "%{http_code}\n" https://irwiki.kamath.cloud/hardware/some-page

# Check old v4 URLs still redirect
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://irwiki.kamath.cloud/Hardware/Old-Uppercase-URL"

Conclusion

Deploying Quartz v5 on Docker gives you a reproducible, self-contained knowledge base that builds identically on any machine. The multi-stage Dockerfile keeps the runtime image tiny (just Nginx and static files), while Git provides instant rollbacks and a complete history of every change.

The setup I’ve described has been running my Inner Range technical reference wiki at irwiki.kamath.cloud — 150 pages, 45 plugins, full-text search, dark mode, and site-wide disclaimers — all from a single repository and a handful of Docker commands.


Related Reading: If you’re managing files across different environments, see my guide on Using Git Safely on Cloud-Synced Folders.

Leave a Reply

Your email address will not be published. Required fields are marked *