Automated Docker Deployment for Quartz Site
Summary: Creating a robust, automated system for deploying a Quartz static site using Docker. The final solution involves a multi-stage Docker build, a shell script to prepare content from an Obsidian vault and manage deployments with Docker Compose, and a cron job for periodic, change-aware updates, successfully overcoming initial challenges with host mounts and build environment issues.
Git Repo: https://git.c0smere.net/wes/digital_garden
Key Topics & Discussion Points
Initial Goal & Challenges
- Objective: Host a Quartz static site within a Docker container.
- Initial Approach (Abandoned): Using Docker host bind mounts for site files.
- Problems Encountered: Complex file permission issues (NGINX 403 errors after rebuilds on host), and potential conflicts with host security modules like AppArmor/SELinux when files were modified outside the container. This led to the decision to move to a fully containerized build and deployment.
Transition to Multi-Stage Docker Builds
- Rationale: To create a self-contained, portable image with the Quartz site and NGINX, avoiding host filesystem dependencies and permission issues for the served content.
- Dockerfile Structure:
- Builder Stage (
node:lts-slim):- Chosen over
node:lts-alpineto resolve an/usr/bin/env: unrecognized option: Serror encountered duringnpx quartz build(Alpine’senvdoesn’t support the-Sflag used by some Node scripts/npx). WORKDIR /appCOPY package*.json ./RUN npm ci --omit=dev(oryarn install) for dependencies.COPY . .to copy the entire Quartz project (including the./contentdirectory prepared by the host script) into the builder.RUN npx quartz buildto compile the Quartz site into/app/public.
- Chosen over
- NGINX Stage (
nginx:alpine):RUN rm /etc/nginx/conf.d/default.confto remove the default NGINX site.COPY custom_nginx.conf /etc/nginx/conf.d/default.confto install the custom NGINX configuration.COPY --from=builder /app/public /usr/share/nginx/htmlto copy only the built static site from the builder stage.EXPOSE 80CMD ["nginx", "-g", "daemon off;"]
- Builder Stage (
- Custom NGINX Configuration (
custom_nginx.conf):- Necessary for handling Quartz’s “clean URLs” (links without
.htmlextensions). - Key directive:
location / { try_files $uri $uri.html $uri/ =404; } - Sets
root /usr/share/nginx/html;andindex index.html;.
- Necessary for handling Quartz’s “clean URLs” (links without
Addressing Symlinked content Directory
- Problem Identified: The user’s local Quartz
contentdirectory was a symlink to a folder within their Obsidian vault. TheCOPY . .Docker instruction copies the symlink itself, which becomes broken inside the container as its target is outside the Docker build context. This resulted innpx quartz buildfinding no content and not generating anindex.htmlin its output. - Solution: Prepare the content on the host before each Docker build by copying the actual files from the Obsidian vault into a real
./contentdirectory within the Docker build context. This is handled by theupdate_quartz_docker.shscript.
Automated Rebuild and Deployment Strategy
- Requirement: Avoid rebuilding the Docker image on every single file change from Obsidian Sync, which could be very frequent. Instead, use a periodic check.
- Solution: A
cronjob executes a master shell script (update_quartz_docker.sh) at a defined interval (e.g., every 10-15 minutes). update_quartz_docker.shScript Functionality:- Configuration: Defines paths for the Quartz project directory and the Obsidian content source directory.
- Change Detection:
- Maintains a timestamp file (e.g.,
.last_build_content_timestamp) in the project directory. - Before attempting a build, it checks if any files in the
OBSIDIAN_CONTENT_SOURCEdirectory are newer than this timestamp file. - If no changes are detected, the script exits early, avoiding unnecessary builds.
- Maintains a timestamp file (e.g.,
- Content Preparation (if changes detected):
- Removes any existing
./contentdirectory or symlink within the Quartz project folder. - Creates a new, empty
./contentdirectory. - Uses
rsync -a --deleteto copy the actual content from theOBSIDIAN_CONTENT_SOURCEpath into the newly created./contentdirectory. This ensures the Docker build context has the real files.
- Removes any existing
- Docker Deployment (using Docker Compose):
docker-compose down --remove-orphans --volumes: Added to ensure a clean state before each deployment, resolving aKeyError: 'ContainerConfig'issue encountered previously.docker-compose up -d --build --remove-orphans: This command tells Docker Compose to:--build: Rebuild the image using theDockerfile(which now has access to the prepared./content).-d: Run in detached mode.up: Create and start containers, recreating them if necessary.--remove-orphans: Cleans up any containers for services no longer in thedocker-compose.yml.
- Timestamp Update: On successful deployment, the script updates the
.last_build_content_timestampfile. - Logging: The script includes
echostatements for logging its actions, typically redirected to a log file by the cron job. - Cleanup (Optional): The script can be configured to remove the copied
./contentdirectory after the build and restore the symlink if desired for local development workflows.
docker-compose.ymlFile:- Defines the
quartz-siteservice. build: .: Instructs Compose to build from the Dockerfile in the current directory.image: my-quartz-site-prod: Names the resulting image.container_name: quartz-prod-container: Assigns a specific name to the container.ports: - "18000:80": Maps port 18000 on the host to port 80 in the container.restart: unless-stopped: Ensures the container restarts automatically unless manually stopped.
- Defines the
Key Files and Configurations
1. Dockerfile:
# Stage 1: Build the Quartz site
FROM node:lts-slim AS builder
# Create a non-root user to own files and run npm commands
USER node
WORKDIR /home/node/app
# Copy the locally cloned Quartz source code from the build context
# This 'quartz' dir is expected to be in the build context root,
# prepared by the update_quartz_docker.sh script.
COPY --chown=node:node quartz/ .
# Install dependencies based on the cloned Quartz source's package.json
RUN npm ci
# Now, copy your specific custom configuration files and the prepared content
# from the root of your deployment repo (the build context's root)
# into the current WORKDIR (/home/node/app), overwriting any placeholders
# from the cloned Quartz source if they share the same names/paths.
COPY --chown=node:node quartz.config.ts ./quartz.config.ts
COPY --chown=node:node quartz.layout.ts ./quartz.layout.ts
# If you have other custom files/dirs at the root of your deployment repo that Quartz needs:
# e.g., custom assets not part of Obsidian content, or custom components to overlay
# COPY --chown=node:node assets/ ./assets/
# COPY --chown=node:node components/ ./components/
# Copy the Obsidian content prepared by the host script into ./content
COPY --chown=node:node content/ ./content/
# Build the Quartz site
RUN npx quartz build
# Output is expected in /home/node/app/public
# Stage 2: Serve the static files with NGINX
FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY custom_nginx.conf /etc/nginx/conf.d/default.conf
# Adjust source path for public files from the builder stage
COPY --from=builder /home/node/app/public /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
2. custom_nginx.conf:
server {
listen 80;
server_name localhost; # Or your actual domain
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri.html $uri/ =404;
}
}
3. docker-compose.yml:
version: '3.8'
services:
quartz-site:
build: .
image: my-quartz-site-prod
container_name: quartz-prod-container
ports:
- "18000:80"
restart: unless-stopped
4. update_quartz_docker.sh:
This one is a bit long to display here: update_quartz_docker.sh
cron support
cronjobs typically run with a very minimalPATHyou’ll probably need to use the full path to invokedocker-composewithin the shell script in order forcronto successfully call it.Relevant line in script:
DOCKER_COMPOSE_CMD="/usr/local/bin/docker-compose"
Decisions Made
- Abandoned Host Mounts: Switched from using Docker host bind mounts for Quartz content to a fully containerized build due to persistent permission and security module (AppArmor/SELinux) complexities.
- Adopted Multi-Stage Docker Builds: Implemented a
Dockerfilewith a Node.js builder stage fornpx quartz buildand an NGINX runtime stage. - Switched Node Base Image: Changed from
node:lts-alpinetonode:lts-slimin the builder stage to resolve the/usr/bin/env: unrecognized option: Serror. - Content Handling for Symlinks: Decided to handle the symlinked
contentdirectory by having a host-side script prepare the actual content within the Docker build context before each build. TheRUN --mountBuildKit approach was explored but ultimately bypassed in favor of the more portable host script method due to complexities and errors. - Automated Deployment Strategy: Implemented a shell script (
update_quartz_docker.sh) to manage content preparation, Docker image building, and container deployment. - Deployment Tool: Chose
docker-composewithin the script for its ease of managing the application lifecycle (build,up,down). - Change Detection Mechanism: The script incorporates a timestamp-based check to only rebuild and redeploy if actual changes are detected in the Obsidian source content directory. Important to note that this doesn’t capture folder name changes. Ultimately that doesn’t really matter much to me, the folder name change will eventually be captured when something else changes (markdown files, config files, etc.)
- Periodic Execution: The
update_quartz_docker.shscript is intended to be run by acronjob. - Docker Compose Error Resolution: Added
docker-compose down --remove-orphans --volumesto the deployment script beforedocker-compose up ...to resolve aKeyError: 'ContainerConfig'and ensure a clean deployment state.