CI/CD for a Clojure SaaS: Forgejo Actions, Podman, and Automated Deployment
Over the past fifteen posts we have built a Clojure/Datomic SaaS piece by piece: strict compilation, server-rendered HTML, passwordless authentication, Datomic modeling, an asset pipeline, end-to-end tests, Lighthouse audits, and more. Each piece works on its own, but without a pipeline that ties them together, shipping is a manual checklist that grows longer with every feature. Forget one step and a broken build makes it to production. Forget two and your users notice.
This final post wires everything into a single automated pipeline. Push to main, and the system formats, lints, builds the static assets and verifies their integrity, runs tests with coverage, executes end-to-end tests in a real browser, audits performance with Lighthouse, builds an uberjar, and deploys -- all without human intervention. That is the goal. Let's build it.
A note on what is actually wired up in the companion repository. The pipeline in this chapter is the application deployment pipeline -- the one you would run for the SaaS itself. The companion repository for this book runs a different, much smaller CI workflow: a single GitHub Actions job that builds these chapters with mdBook and publishes the result to GitHub Pages. The application pipeline below (uberjar, Tailwind, esbuild, asset verification, Caddy, SSH deploy) is taught here in full, but it is not the workflow that ships this book's prose. Where it matters -- the asset-integrity gate especially -- I will be explicit about whether a step runs continuously or as a local pre-deploy check. Treat the application pipeline as the design you would adopt the day you stand up your own forge runner; the asset-integrity gate is useful today, even run by hand before a deploy.
Why Forgejo Actions
Forgejo is a self-hosted Git forge (a fork of Gitea) that includes a built-in CI/CD system called Forgejo Actions. The workflow syntax is compatible with GitHub Actions, which means you get the benefit of a familiar YAML-based pipeline definition without depending on GitHub's infrastructure. For a self-hosted SaaS where you already run your own Git server, this is a natural fit. No external CI service to pay for, no secrets leaving your network, and the CI runner lives on the same infrastructure as everything else.
The key difference from GitHub Actions is the runner model. Instead of ephemeral VMs, Forgejo Actions can run directly on the host machine. We use runs-on: host because our pipeline runs each step inside a Podman container anyway -- the host is just the orchestrator. This gives us full control over caching, networking, and the container runtime.
The CI Container Image
Every CI step (except checkout and deploy) runs inside a purpose-built container. This ensures the CI environment is reproducible and isolated from whatever is installed on the host. Here is the Dockerfile:
# CI build image for Forgejo Actions
# Contains only what's needed to lint, test, and build the uberjar.
FROM docker.io/library/debian:trixie
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
wget \
git \
gpg \
ca-certificates \
openssh-client \
rlwrap \
locales \
unzip \
&& sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
&& dpkg-reconfigure --frontend=noninteractive locales \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Java
RUN wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public \
| gpg --dearmor | tee /etc/apt/trusted.gpg.d/adoptium.gpg > /dev/null \
&& echo "deb https://packages.adoptium.net/artifactory/deb \
$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" \
| tee /etc/apt/sources.list.d/adoptium.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends temurin-25-jdk rlwrap \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/temurin-25-jdk-amd64
# Install Clojure CLI
RUN curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh \
&& chmod +x linux-install.sh \
&& ./linux-install.sh \
&& rm linux-install.sh \
&& clojure -M -e '(println "Clojure installed")'
# Install babashka
RUN curl -s https://raw.githubusercontent.com/babashka/babashka/master/install | bash
# Install zprint formatter
RUN curl -sL https://github.com/kkinnear/zprint/releases/download/1.3.0/zprintl-1.3.0 \
-o /usr/local/bin/zprint \
&& chmod +x /usr/local/bin/zprint
# Install clj-kondo linter
RUN curl -sL https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo \
| bash
# Install Node.js, Playwright, Lighthouse CI, and Tailwind CSS
ENV NVM_DIR=/usr/local/nvm
RUN mkdir -p "$NVM_DIR" \
&& curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \
&& . "$NVM_DIR/nvm.sh" \
&& nvm install --lts \
&& nvm alias default lts/* \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/node" /usr/local/bin/node \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/npm" /usr/local/bin/npm \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/npx" /usr/local/bin/npx \
&& npm install -g @playwright/test @lhci/cli tailwindcss @tailwindcss/cli \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/playwright" /usr/local/bin/playwright \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/lhci" /usr/local/bin/lhci \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/tailwindcss" /usr/local/bin/tailwindcss \
&& playwright install --with-deps \
&& ln -s "$(find /root/.cache/ms-playwright -name chrome \
-type f -path '*/chrome-linux64/*' | head -1)" /usr/local/bin/chromium
ENV NODE_PATH=/usr/local/lib/node_modules
A few design choices worth explaining.
Debian Trixie as the base. We need a recent enough base to support current versions of Java, Node, and Chromium. Debian is stable, well-understood, and produces reasonably small images.
Everything is pinned or versioned. Java comes from Adoptium's Temurin 25 JDK. The zprint binary is pinned to 1.3.0. Clojure CLI pulls the latest stable release. The goal is reproducibility -- you want the same CI results whether you run the pipeline today or three months from now.
Playwright with Chromium. The playwright install --with-deps command downloads Chromium and all its system dependencies (X11 libraries, fonts, etc.) into the container. The final ln -s creates a /usr/local/bin/chromium symlink so Lighthouse CI can find the browser without extra configuration.
Symlinks for global tools. NVM installs Node into a deeply nested versioned directory. The symlinks make node, npm, npx, playwright, lhci, and tailwindcss available on the default PATH. Without these, every podman run command would need to source NVM first.
Why not a multi-stage build? The CI image is a build tool, not a production artifact. We want everything in one layer so the container starts fast with no copying between stages. Image size matters less here than build speed.
The Pipeline
Here is the complete ci.yml workflow:
name: CI
on:
push:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
pull_request:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
env:
CI_IMAGE: myapp-ci:latest
APP_HOST: ${{ vars.APP_HOST }}
CACHE_VOLS: >-
-v /var/cache/ci/m2:/root/.m2:Z
-v /var/cache/ci/gitlibs:/root/.gitlibs:Z
jobs:
build-and-deploy:
runs-on: host
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create cache dirs
run: mkdir -p /var/cache/ci/m2 /var/cache/ci/gitlibs
- name: Build CI image
run: podman build -t $CI_IMAGE -f .forgejo/ci.Dockerfile .
- name: Check formatting
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
bash -O globstar -c "zprint '{:search-config? true}' -c src/**/*.clj test/**/*.clj"
- name: Lint with clj-kondo
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
clj-kondo --lint src test
- name: Build static assets
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build assets
- name: Verify asset integrity
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build verify-assets
- name: Run tests with coverage
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -M:coverage
- name: Run e2e tests
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
-e CI=true
$CI_IMAGE
playwright test --config playwright.config.js
- name: Run Lighthouse CI
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
lhci autorun
- name: Build uberjar
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build uber
- name: Verify jar exists
run: test -f myapp/target/myapp.jar
- name: Deploy static files
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp -r $SSH_OPTS myapp/static/* deploy@$APP_HOST:/mnt/data/static/
- name: Deploy app
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp $SSH_OPTS myapp/target/myapp.jar deploy@$APP_HOST:/tmp/myapp.jar
ssh $SSH_OPTS deploy@$APP_HOST '/etc/scripts/deploy-myapp.sh /tmp/myapp.jar'
Let's walk through every stage.
Stage by Stage
Triggers and Path Filtering
on:
push:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
pull_request:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
The pipeline triggers on pushes to main and on pull requests targeting main, but only when files under myapp/ or .forgejo/ change. If you edit infrastructure configs, documentation, or feature specs, the CI pipeline does not run. This is important in a monorepo -- you do not want to spend ten minutes building and testing the application because someone updated a README in a different directory.
Environment Variables
env:
CI_IMAGE: myapp-ci:latest
APP_HOST: ${{ vars.APP_HOST }}
CACHE_VOLS: >-
-v /var/cache/ci/m2:/root/.m2:Z
-v /var/cache/ci/gitlibs:/root/.gitlibs:Z
Three variables defined at the workflow level so every step can use them.
CI_IMAGE is the tag for the CI container image. It is built fresh from the Dockerfile on every run to ensure the CI environment always matches the Dockerfile in the repository.
APP_HOST comes from a Forgejo repository variable (configured in Settings -> Actions -> Variables). This keeps the production hostname out of the workflow file. If you move to a different server, you change the variable, not the pipeline.
CACHE_VOLS is the key to fast builds. These are Podman bind mounts that map host directories into the container. Let's look at this more closely.
Cache Volumes
CACHE_VOLS: >-
-v /var/cache/ci/m2:/root/.m2:Z
-v /var/cache/ci/gitlibs:/root/.gitlibs:Z
Clojure's dependency resolution downloads JARs to ~/.m2 (the Maven local repository) and Git-based deps to ~/.gitlibs. Without caching, every CI run downloads the entire dependency tree from scratch. For a project with dozens of dependencies, that is minutes of network I/O.
By mounting host directories into the container, dependencies persist across runs. The first build downloads everything; subsequent builds resolve from the local cache in seconds. The :Z suffix is a SELinux relabeling flag that Podman needs on systems with SELinux enabled (like Fedora or RHEL-based hosts). It tells Podman to relabel the mount so the container process can read and write to it.
The Create cache dirs step ensures these host directories exist before any container tries to mount them:
- name: Create cache dirs
run: mkdir -p /var/cache/ci/m2 /var/cache/ci/gitlibs
Podman, Not Docker
Every containerized step uses podman run instead of docker run. Podman is a daemonless container runtime -- there is no background daemon process managing containers. Each container is a child process of the calling shell. This matters for CI because:
- No daemon dependency. If a Docker daemon crashes, all containers stop. With Podman, each container is independent.
- Rootless capable. Podman can run containers without root privileges, though in our CI setup we run as root for simplicity.
- CLI-compatible with Docker. The command-line interface is nearly identical, so if you already know Docker, you know Podman.
The pattern for every CI step is the same:
podman run --rm
-v ${{ github.workspace }}:/workspace:Z # mount the checkout
$CACHE_VOLS # mount dependency caches
-w /workspace/myapp # set working directory
$CI_IMAGE # use the CI image
<command> # run the tool
--rm removes the container after it exits. The workspace is mounted at /workspace inside the container, and the working directory is set to the application subdirectory. This means every tool sees the same file layout as a developer working locally.
Check Formatting
- name: Check formatting
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
bash -O globstar -c "zprint '{:search-config? true}' -c src/**/*.clj test/**/*.clj"
This runs zprint in check mode (-c) across all Clojure files in src/ and test/. The -c flag means zprint reads each file, formats it in memory, and compares the result to the original. If they differ, the file is not formatted correctly and the step fails with a non-zero exit code.
The bash -O globstar enables recursive globbing (**), which is not on by default in bash. The {:search-config? true} option tells zprint to find the .zprintrc configuration file by walking up the directory tree from each file.
Note that this step does not mount the cache volumes. Formatting does not need Maven dependencies -- it only needs the source files and the zprint binary.
Lint with clj-kondo
- name: Lint with clj-kondo
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
clj-kondo --lint src test
Static analysis across all source and test files. clj-kondo reads the .clj-kondo/config.edn from the project root for linter configuration (covered in detail in the strict-compilation chapter). Like formatting, this step does not need the cache volumes -- clj-kondo analyzes source code directly without resolving dependencies.
Build Static Assets and Verify Their Integrity
- name: Build static assets
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build assets
- name: Verify asset integrity
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build verify-assets
These two steps are where the asset pipeline from earlier in the series (the assets and verify-assets build tasks) becomes a deployment gate. They mount $CACHE_VOLS because both run under clojure -T:build, which needs resolved dependencies.
clojure -T:build assets produces the served tree. It runs Tailwind CSS once over input.css to emit the minified stylesheet, content-hashes it to styles.<hash>.css; runs esbuild over each ESM module under static/js/ to minify it (no bundling, absolute imports preserved) and content-hash it; minifies the vendored idiomorph source into idiomorph-0.7.4.min.js with a sourcemap (its version lives in the filename, so it is not content-hashed); copies fonts, SVGs and the error pages through unchanged; and finally writes asset-manifest.edn -- a map of {:assets {logical-name url} :sri {url sri}} that the running application reads at boot to resolve each logical asset name to its hashed URL and Subresource-Integrity token. The whole tree, plus the manifest, lands under myapp/static/ (which is gitignored -- only the sources under static/ are committed).
clojure -T:build verify-assets is the integrity gate. It does not re-run Tailwind or esbuild, and it does not compare against committed bytes. It asserts three invariants about the tree assets just produced:
- An
asset-manifest.ednexists (if not, it tells you to runclojure -T:build assetsfirst). - Every URL named in the manifest points at a file that actually exists on disk.
- Every content-hashed filename matches the SHA-256 of its own bytes -- so a filename can never lie about its contents. A
styles.2c7c3332.csswhose bytes hash to something other than2c7c3332fails the gate.
That third invariant is the load-bearing one. Because Caddy serves content-hashed assets with Cache-Control: public, max-age=31536000, immutable, a wrong hash is not a cosmetic bug -- it is a year-long cache poisoning. verify-assets catches a corrupted or hand-edited build artifact before it can be deployed under an immutable URL. Run it locally right before you ship; if you stand up an application CI runner, it slots in immediately after the assets step, exactly as shown above.
There is no separate scripts/verify-css-hash.bb or verify-css task; asset integrity lives entirely in build.clj as verify-assets, covering CSS, JavaScript and the manifest in one pass.
Run Tests with Coverage
- name: Run tests with coverage
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -M:coverage
Runs the test suite with code coverage tracking via the :coverage alias. This executes all unit and integration tests and produces a coverage report. A failing test means a non-zero exit code, which fails the pipeline step.
Run End-to-End Tests
- name: Run e2e tests
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
-e CI=true
$CI_IMAGE
playwright test --config playwright.config.js
This is where the Chromium browser inside the CI image earns its keep. Playwright launches a headless browser, starts the application, and runs through user flows -- clicking buttons, filling forms, verifying page content. The -e CI=true environment variable tells the test configuration to adjust timeouts and other settings for CI (where things may be slightly slower than on a developer machine).
These tests catch an entire class of bugs that unit tests miss: broken routes, missing templates, JavaScript errors, form submissions that silently fail. They are slower than unit tests but worth every second.
Run Lighthouse CI
- name: Run Lighthouse CI
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
lhci autorun
Lighthouse CI starts the application, loads key pages in Chromium, and measures performance, accessibility, best practices, and SEO. The lhci autorun command reads its configuration from a lighthouserc.js file in the project root, which defines which URLs to audit and what score thresholds to enforce.
If performance drops below the threshold -- maybe someone added a render-blocking resource or a large unoptimized image -- the pipeline fails. This catches performance regressions before they reach users, automatically and on every push.
Build the Uberjar
- name: Build uberjar
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build uber
- name: Verify jar exists
run: test -f myapp/target/myapp.jar
The uberjar build compiles all Clojure source with strict compilation flags (*warn-on-reflection* and *unchecked-math* :warn-on-boxed), copies resources, and packages everything into a single JAR file. As covered in the strict-compilation chapter, the build fails if any reflection or boxed math warnings are detected in application code.
The Verify jar exists step is a simple sanity check that runs on the host (not in a container). If the uberjar build succeeded but somehow did not produce the expected file, this catches it.
Conditional Deployment
- name: Deploy static files
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp -r $SSH_OPTS myapp/static/* deploy@$APP_HOST:/mnt/data/static/
- name: Deploy app
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp $SSH_OPTS myapp/target/myapp.jar deploy@$APP_HOST:/tmp/myapp.jar
ssh $SSH_OPTS deploy@$APP_HOST '/etc/scripts/deploy-myapp.sh /tmp/myapp.jar'
Deployment only happens when two conditions are met: the branch is main and the event is a push (not a pull request). Pull requests run the full pipeline -- format, lint, test, build -- but stop short of deploying. This gives you confidence that a PR is ready to merge without actually touching production.
The deployment itself is straightforward:
-
Static files are copied via
scpfrommyapp/static/-- the built tree thatclojure -T:build assetsproduced andverify-assetsjust signed off on -- to the directory the reverse proxy serves. That tree contains the content-hashed stylesheet and ESM modules, the version-pinnedidiomorph-0.7.4.min.js(and its sourcemap), the passed-through fonts and SVGs, andasset-manifest.edn. The manifest must travel with the assets: the running application reads it at boot to map each logical asset name to its hashed URL and SRI token, so deploying the hashed files without the manifest would leave the app unable to resolve them. -
The uberjar is copied to
/tmp/on the server, then a deploy script moves it into place and restarts the application. The deploy script handles the atomic swap: stop the running process, move the new JAR into position, start the new process. This keeps downtime to a few seconds.
Static assets are served by Caddy, not by the JVM. In the companion repository's Caddyfile, the proxy applies Cache-Control: public, max-age=31536000, immutable to any content-hashed filename (the \.([a-f0-9]{8})\.(css|js)$ pattern) and to the version-pinned idiomorph-*.min.js, and it sets the long-lived security headers (HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and the Cross-Origin-Opener-Policy/Cross-Origin-Resource-Policy isolation pair). It deliberately does not set a Content-Security-Policy: the per-document CSP carries the SHA-256 hashes of the application's inline scripts and import map, so the application must own it. This is why immutable caching and asset integrity are two sides of one coin -- once a hashed file is out, browsers may cache it for a year, so verify-assets is the last chance to catch a hash that does not match its bytes.
The SSH key (deploy_ed25519) is pre-installed on the CI runner and grants access to a deploy user on the application server. This user has limited permissions -- it can write to the static directory and execute the deploy script, nothing else.
The Pipeline Order
The stages are deliberately ordered from fastest to slowest, and from cheapest to most expensive:
- Format check -- Seconds. No dependencies needed. Catches formatting issues before anything else runs.
- Lint -- Seconds. Static analysis only. Catches code quality issues early.
- Build assets -- Seconds. Runs Tailwind once and esbuild per module to produce the hashed, minified served tree plus the manifest.
- Verify asset integrity -- A fraction of a second. No tools, just hashing: every content-hashed filename must match its bytes and every manifest target must exist.
- Coverage -- Tens of seconds. Runs the test suite. First stage that executes application code.
- E2E tests -- Minutes. Launches a browser and runs user flows. Expensive but catches integration bugs.
- Lighthouse -- Minutes. Launches a browser and audits performance. Catches regressions.
- Uberjar -- Tens of seconds. Compiles everything with strict flags. Produces the deployable artifact.
- Deploy -- Seconds. Copies files and restarts the app.
If formatting is wrong, you find out in seconds, not after waiting for the entire test suite and build to run. This fast feedback loop makes CI less painful -- developers fix issues quickly because the pipeline tells them quickly.
What You Have Now
Once this pipeline is wired up to your forge runner, every push to main triggers a fully automated sequence:
- Code quality gates: formatting with zprint, static analysis with clj-kondo
- Asset integrity: the
assetsbuild produces the hashed, minified served tree and manifest;verify-assetsproves every content-hashed filename matches its bytes and every manifest target exists - Correctness verification: unit tests with coverage, end-to-end browser tests
- Performance assurance: Lighthouse CI audits with score thresholds
- Build integrity: strict AOT compilation that rejects reflection and boxed math warnings
- Automated deployment: conditional deployment of the built static tree (with its manifest) and the uberjar via SSH
The entire pipeline runs inside Podman containers built from a single Dockerfile, using host-mounted cache volumes for fast dependency resolution. Pull requests get the full quality gate treatment without deploying. Only pushes to main go to production.
There is nothing to remember. No manual checklist. No "did I run the tests?" anxiety before deploying. The pipeline enforces the standards every time, whether it is Monday morning or Friday evening.
A closing honesty about scope: the workflow above is the application pipeline. The repository that holds these chapters runs only the mdBook-to-Pages job described at the top of this chapter -- the prose ships continuously; the application pipeline is the design you reach for the day you run your own SaaS on your own runner. Until then, clojure -T:build assets followed by clojure -T:build verify-assets is a two-command pre-deploy ritual you can run by hand: build the tree, prove its integrity, then ship it.
Reflecting on the Series
This is the final post in "Building a Clojure/Datomic SaaS from Scratch." Over the course of this series, we have assembled a complete application stack from first principles:
We started with a Clojure project structure and strict compilation. We added server-side rendering with escaping Hiccup. We modeled our domain in Datomic. We built passwordless authentication. We built a content-hashed asset pipeline -- Tailwind, esbuild, an import map and Subresource Integrity -- behind a strict Content-Security-Policy. We wrote tests at every level -- unit, integration, and end-to-end. We measured performance with Lighthouse. And now we have tied everything together with continuous integration and automated deployment.
Every piece was built to be understood by one person. No magic frameworks, no hidden abstractions, no build tools that require a dedicated team to maintain. A single Dockerfile for CI. A single YAML file for the pipeline. SSH and a shell script for deployment.
That simplicity is the point. A solo operator building a SaaS does not have the luxury of complexity. Every moving part is a part that can break at 2 AM with no one else to call. The system we have built is not sophisticated -- it is simple, transparent, and entirely within one person's ability to understand, debug, and maintain.
Whether you followed along post by post or jumped straight to the topics that interested you, I hope this series demonstrated that building a production SaaS with Clojure and Datomic is not only feasible but genuinely enjoyable. The tools are mature. The ecosystem is stable. And the language rewards you with a codebase that stays manageable as it grows.
Now go build something.