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.