cmc/cleberg.net

My personal web garden & blog.

clone: git clone https://gitbay.org/cmc/cleberg.net.git

main: content/blog/2026-02-12-automating-weblorg-deployments.org · raw

  1#+date:        [2026-02-12 Thu 20:26:32]
  2#+title:       Automating weblorg Deployments
  3#+description: How I automated deployment for this org-mode blog without GitHub Actions.
  4#+slug: automating-weblorg-deployments
  5#+filetags:    :self-hosting:web:
  6
  7As I've mentioned in previous posts, I utilize a unique pipeline to draft posts,
  8compose my website, and to build and deploy the static files.
  9
 10This stack uses the following software:
 11- [[https://www.gnu.org/software/emacs/][Emacs]]
 12- [[https://emacs.love/weblorg/][Weblorg]]
 13- [[https://www.python.org/][Python]]
 14- [[https://formulae.brew.sh/formula/minify][Minify]]
 15- [[https://rsync.samba.org/][rsync]]
 16- Environment variables
 17
 18I've historically relied on the following build and deployment methods:
 19
 201. Manually running ~ENV=prod emacs --script publish.el~;
 212. Then building out a ~build.py~ script to automate the Weblorg publishing method
 22   and allow for custom steps, like adding recent blog posts to ~index.html~;
 233. Then adding GitHub Actions to automate all steps whenever I merge a pull
 24   request into ~main~.
 25
 26This post will describe the process I've created to automatically build and
 27deploy my site with this stack via GitHub Actions.
 28
 29* Weblorg Configuration
 30
 31The basis for the build process is ~publish.el~. The challenge with using Emacs
 32static site generators is path management. Specifically, I've needed to ensure
 33that the necessary packages (~weblorg~, ~htmlize~, & ~templatel~) are available
 34regardless of whether I'm building the site on macOS (my dev machine) or a
 35Linux-based runner.
 36
 37To solve this, I use a simple conditional to set the ~site-lisp-base~ path. This
 38allows the script to find the cloned repositories in their respective locations.
 39Additionally, I use an environment variable check (~ENV=prod~) to toggle the
 40~weblorg-default-url~. If I’m just testing locally, it defaults to ~localhost~.
 41Otherwise, it points to the live domain.
 42
 43#+begin_src elisp
 44;;; -*- lexical-binding: t -*-
 45;; Allow for macOS (dev machine) & Linux (GitHub Actions) execution
 46(defvar site-lisp-base
 47  (if (eq system-type 'darwin)
 48      "~/.config/emacs/.local/straight/repos"                 ; macOS path
 49      "/home/linuxbrew/.config/emacs/.local/straight/repos")) ; CI/Linux path
 50
 51;; Explicitly load packages
 52(add-to-list 'load-path (expand-file-name "htmlize" site-lisp-base))
 53(add-to-list 'load-path (expand-file-name "weblorg" site-lisp-base))
 54(add-to-list 'load-path (expand-file-name "templatel" site-lisp-base))
 55
 56(require 'htmlize)
 57(require 'weblorg)
 58
 59;; Set default URL for Weblorg
 60;; Only works if environment variable ENV=prod
 61(if (string-equal-ignore-case (getenv "ENV") "prod")
 62    (setq weblorg-default-url "https://cleberg.net"))
 63
 64;; Define site metadata
 65(weblorg-site
 66 :theme nil
 67 :template-vars '(("site_name"        . "cleberg.net")
 68                  ("site_owner"       . "Christian Cleberg <hello@cleberg.net>")
 69                  ("site_description" . "Just a blip of ones and zeroes.")))
 70
 71;; Define routes for rendering content
 72;; ...
 73;; /scrubbed for brevity/
 74
 75;; Export all content using Weblorg engine
 76(weblorg-export)
 77#+end_src
 78
 79If we run a command such as ~ENV=prod emacs --script publish.el~, Emacs will
 80return a ~.build/~ directory with our resulting HTML files. At this point, we
 81could manually enter the ~.build/~ directory and run ~python -m http.server~ for a
 82local dev server or ~rsync~ to deploy to production.
 83
 84However, that's just way too much work. Let's keep going.
 85
 86* Python Build Script
 87
 88Building on the previous step, I wanted to add some quality-of-life improvements
 89that Weblorg does not provide:
 90- Update ~index.html~ with the three latest blog posts.
 91- Clean up the ~.build/~ directory with each run so we don't run into any
 92  conflicts with old or removed files.
 93- Minify CSS and HTML.
 94- Silence Emacs/Weblorg ~stdout~ / ~stderr~ when running for production.
 95- Generate a sitemap.
 96- Allow the option to deploy to a remote endpoint via ~rsync~ or start the local
 97  dev server.
 98
 99Python allows for this by acting as the orchestrator, as well as relying on
100environment variables to decide its behavior:
101- *ENV*: Determines if we use production URLs or local ones.
102- *BUILD*: Triggers the actual Emacs export and asset minification.
103- *DEPLOY*: In a local context, this spins up a dev server. In CI, we leave this
104  ~false~ because GitHub Actions handles the ~rsync~ logic separately.
105
106
107See below for the ~main()~ function within ~build.py~ for the logic used to drive
108the process to the rest of the functions in the Python file.
109
110#+begin_src python
111# File scrubbed for brevity
112
113def main():
114    # Updates index.html with the 3 most recent blog posts
115    html_snippet = get_recent_posts_html("./content/blog", num_posts=3)
116
117    # Defines the build path, theme path, and CSS paths
118    build_dir = Path(".build")
119    theme_dir = Path("theme/static")
120    css_src = theme_dir / "styles.css"
121    css_min = theme_dir / "styles.min.css"
122
123    # Check environment for ENV, BUILD, and DEPLOY variables
124    env = os.environ.get("ENV", "").casefold()
125    build = os.environ.get("BUILD", "").casefold() == "true"
126    deploy = os.environ.get("DEPLOY", "").casefold() == "true"
127
128    if env == "prod":
129        # If ENV = prod (case-insensitive), will build for production
130        print("Environment: Production")
131        # Will only build if BUILD=true
132        if build:
133            remove_build_directory(build_dir)
134            minify_css(css_src, css_min)
135            run_emacs_publish(dev_mode=False)
136            update_index_html(html_snippet)
137            minify_html("./.build/index.html", "./.build/index.html")
138            generate_sitemap()
139        # Will only deploy if DEPLOY=true
140        # False for GitHub Actions because deploy.yml deploys via rsync directly
141        if deploy:
142            print("Deploying to production...")
143            deploy_to_server(build_dir, "homelab-remote")
144            return
145    else:
146        # If ENV != prod (case-insensitive), will build for localhost
147        print("Environment: Development")
148        # Will only build if BUILD=true
149        if build:
150            remove_build_directory(build_dir)
151            minify_css(css_src, css_min)
152            run_emacs_publish(dev_mode=True)
153            update_index_html(html_snippet)
154            minify_html("./.build/index.html", "./.build/index.html")
155            generate_sitemap()
156        # Will only deploy if DEPLOY=true
157        if deploy:
158            start_dev_server(build_dir)
159#+end_src
160
161Awesome! Now we can run ~uv run build.py~ to build and deploy locally or ~ENV=prod
162uv run build.py~ to build and deploy for production. Enabling ~BUILD~ and ~DEPLOY~
163variables will tweak the process, as mentioned above.
164
165However, that's way too manual for me. Let's be lazy and take it even further.
166
167* GitHub Actions
168
169So, how do we push it further? By removing the need to run a command (outside of
170~git~) at all!
171
172This process will:
1731. Create a custom Docker image with the tools we need to build and deploy.
1742. Build the Docker image and store it within GitHub's image registry.
1753. Build and deploy the website upon a push or pull request to ~main~.
176
177** The Custom Docker Image
178
179Let's start by building a Docker image that has all the tools I need to build
180the site. Standard CI runners don't come pre-installed with the specific mix of
181tools I need (Emacs, Homebrew, ~uv~, and ~minify~). Instead of installing these on
182every single run, we will build the image and store it for future use.
183
184The ~Dockerfile~ uses ~python:3.12-slim~ as a base, installs Linuxbrew for easy
185package management, and clones the necessary Emacs packages into the expected
186directory. This ensures the build environment is consistent and fast.
187
188#+begin_src Dockerfile
189FROM python:3.12-slim
190
191ENV DEBIAN_FRONTEND=noninteractive \
192    HOMEBREW_NO_AUTO_UPDATE=1 \
193    PATH="/home/linuxbrew/.linuxbrew/bin:${PATH}"
194
195RUN apt-get update && apt-get install -y --no-install-recommends \
196    curl \
197    git \
198    procps \
199    build-essential \
200    ca-certificates \
201    openssh-client \
202    && rm -rf /var/lib/apt/lists/*
203
204RUN useradd -m -s /bin/bash linuxbrew
205USER linuxbrew
206WORKDIR /home/linuxbrew
207
208RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
209
210RUN brew install emacs rsync uv minify
211
212RUN mkdir -p ~/.config/emacs/.local/straight/repos && \
213    cd ~/.config/emacs/.local/straight/repos && \
214    git clone --depth 1 https://github.com/emacsorphanage/htmlize.git && \
215    git clone --depth 1 https://github.com/emacs-love/templatel.git && \
216    git clone --depth 1 https://github.com/emacs-love/weblorg.git
217
218USER root
219WORKDIR /builds
220#+end_src
221
222** Building and Pushing to GHCR
223
224Next, let's use the image we built as the base for the rest of our automation. I
225use a dedicated workflow (~docker-build.yml~) to keep the image up to date.
226Whenever I modify the Dockerfile or my requirements, GitHub Actions builds the
227image and pushes it to the GitHub Container Registry (GHCR). This image then
228serves as the environment for the final deployment step.
229
230#+begin_src yaml
231name: Build and Push Docker Image
232
233on:
234  push:
235    branches: [ "main" ]
236    paths:
237      - 'Dockerfile'
238      - 'requirements.txt'
239      - '.github/workflows/docker-build.yml'
240
241jobs:
242  build:
243    runs-on: ubuntu-latest
244    permissions:
245      contents: read
246      packages: write
247
248    steps:
249      - name: Checkout repository
250        uses: actions/checkout@v4
251
252      - name: Log in to GHCR
253        uses: docker/login-action@v3
254        with:
255          registry: ghcr.io
256          username: ${{ github.actor }}
257          password: ${{ secrets.GITHUB_TOKEN }}
258
259      - name: Extract metadata
260        id: meta
261        uses: docker/metadata-action@v5
262        with:
263          images: ghcr.io/${{ github.repository }}
264
265      - name: Build and push
266        uses: docker/build-push-action@v5
267        with:
268          context: .
269          push: true
270          tags: ${{ steps.meta.outputs.tags }}
271          labels: ${{ steps.meta.outputs.labels }}
272#+end_src
273
274** The Build and Deploy Workflow
275
276Finally, the ~deploy.yml~ brings it all together. I split into two jobs: the
277*build-job*, which runs inside our custom container to execute the Python
278orchestrator, and the *deploy-job*, which handles the SSH handshake and ~rsync~
279transfer.
280
281#+begin_src yaml
282name: Build and Deploy
283
284on:
285  push:
286    branches:
287      - main
288    paths-ignore:
289      - '.github/**'
290      - 'screenshots/**'
291      - 'utils/**'
292      - 'LICENSE'
293      - 'README.org'
294
295jobs:
296  build-job:
297    runs-on: ubuntu-latest
298    container:
299      image: ghcr.io/ccleberg/cleberg.net:main
300
301    steps:
302      - name: Checkout code
303        uses: actions/checkout@v4
304
305      - name: Run Build
306        env:
307          ENV: "prod"
308          BUILD: "true"
309          DEPLOY: "false"
310        run: |
311          echo "Environment is ready. Running build..."
312          uv run build.py
313
314      - name: Upload Build Artifacts
315        uses: actions/upload-artifact@v4
316        with:
317          name: build-output
318          path: ${{ github.workspace }}/.build/
319          include-hidden-files: true
320
321  deploy-job:
322    runs-on: ubuntu-latest
323    needs: build-job
324    environment: production
325    container:
326      image: ghcr.io/ccleberg/cleberg.net:main
327
328    steps:
329      - name: Checkout code
330        uses: actions/checkout@v4
331
332      - name: Download Build Artifacts
333        uses: actions/download-artifact@v4
334        with:
335          name: build-output
336          path: ${{ github.workspace }}/.build/
337
338      - name: Setup SSH and Deploy
339        env:
340          SERVER_IP: ${{ secrets.SERVER_IP }}
341          SERVER_USER: ${{ secrets.SERVER_USER }}
342          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
343        run: |
344          eval $(ssh-agent -s)
345          echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
346          rsync -avz --delete \
347            -e "ssh -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
348            .build/ \
349            $SERVER_USER@$SERVER_IP:/var/www/cleberg.net/
350#+end_src
351
352* Conclusion
353
354Amazing! Now my site will build and deploy whenever I push to the ~main~ branch. I
355have more tweaks to make (e.g., build a development server and environment for
356pull requests prior to ~main~), but I've automated most of it and have drastically
357reduced the administrative burden for the site. After making updates, I simply
358need to ~git add ...~ and merge my PR to trigger the deployment.