diff --git a/ci-builds.nix b/ci-builds.nix
index d29edac43..54d6f637a 100644
--- a/ci-builds.nix
+++ b/ci-builds.nix
@@ -18,6 +18,7 @@ in with pkgs; [
tools.blog_cli
tools.cheddar
tools.emacs
+ web.blog
web.cgit-taz
lisp.dns
third_party.cgit
diff --git a/web/blog/.skip-subtree b/web/blog/.skip-subtree
new file mode 100644
index 000000000..e7fa50d49
--- /dev/null
+++ b/web/blog/.skip-subtree
@@ -0,0 +1 @@
+Subdirectories contain blog posts and static assets only
diff --git a/web/blog/default.nix b/web/blog/default.nix
new file mode 100644
index 000000000..bc7b9666f
--- /dev/null
+++ b/web/blog/default.nix
@@ -0,0 +1,46 @@
+# This creates the static files that make up my blog from the Markdown
+# files in this repository.
+#
+# All blog posts are rendered from Markdown by cheddar.
+{ pkgs, lib, ... }@args:
+
+with pkgs.nix.yants;
+
+let
+ # Type definition for a single blog post.
+ post = struct "blog-post" {
+ key = string; #
+ title = string;
+ date = string; # *sigh*
+
+ # Path to the Markdown file containing the post content.
+ content = path;
+
+ # Should this post be included in the index? (defaults to true)
+ listed = option bool;
+
+ # Is this a draft? (adds a banner indicating that the link should
+ # not be shared)
+ draft = option bool;
+
+ # Previously each post title had a numeric ID. For these numeric
+ # IDs, redirects are generated so that old URLs stay compatible.
+ oldKey = option string;
+ };
+
+ posts = list post (import ./posts.nix);
+ fragments = import ./fragments.nix args;
+
+ renderedBlog = pkgs.third_party.runCommandNoCC "tazjins-blog" {} ''
+ mkdir -p $out
+
+ cp ${fragments.blogIndex posts} $out/index.html
+
+ ${lib.concatStringsSep "\n" (map (post:
+ "cp ${fragments.renderPost post} $out/${post.key}.html"
+ ) posts)}
+ ''; # '' (this line makes nix-mode happy :/)
+
+in import ./nginx.nix (args // {
+ inherit posts renderedBlog;
+})
diff --git a/web/blog/fragments.nix b/web/blog/fragments.nix
new file mode 100644
index 000000000..2c9127b7f
--- /dev/null
+++ b/web/blog/fragments.nix
@@ -0,0 +1,81 @@
+# This file defines various fragments of the blog, such as the header
+# and footer, as functions that receive arguments to be templated into
+# them.
+#
+# An entire post is rendered by `renderPost`, which assembles the
+# fragments together in a runCommand execution.
+#
+# The post overview is rendered by 'postList'.
+{ pkgs, lib, ... }:
+
+let
+ inherit (builtins) filter map hasAttr replaceStrings toFile;
+ inherit (pkgs.third_party) runCommandNoCC writeText;
+
+ escape = replaceStrings [ "<" ">" "&" "'" ] [ "<" ">" "&" "'" ];
+
+ header = title: ''
+
+