A Docker Compose Setup for Clojure/Script Development

August 28, 2021 | 15 min. read


In the past few weeks I've been learning the Clojure programming language. I really like it so far, and it feels like a (well designed) breath of fresh air from my day-to-day work in PHP and JavaScript. In addition to doing Advent of Code problems to familiarize myself with the standard library and thinking about problems the Clojure way, I've been messing around with the Luminus web It's actually not strictly a "framework", and instead is a project template including several heterogenous Clojure libraries for things like routing, HTML templating, database connections, etc. I'd be interested in a more comprehensive, batteries-included framework like Rails or Laravel but you get most of what you need from the included libraries and rolling your own stuff beyond that isn't too hard..

My preferred web development workflow is on my local machine using Docker Compose, but unfortunately Luminus doesn't come with an out-of-the-box docker-compose.yml template. I've come up with one that works pretty well, and I'm happy to share it here.

Installing Leiningen & Creating a Luminus Project

A prerequisite to all of this, obviously, is installing Clojure. Clojure is hosted on the JVM, so you'll need a JDK version 8 or higher.

The most frictionless way to get a new Clojure project up and running is with Leiningen. Installation instructions can be found here. To make sure you have it installed, you can run lein --version. It'll probably chug for a bit while spinning up the JVM, then spit out a version number. Here's what I got:

$ lein --version
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
Leiningen 2.9.6 on Java 16.0.2 OpenJDK 64-Bit Server VM

Leiningen has a pretty handy templating feature that allows for different project structures based on what template is used. For example, lein new default my-library will create a new project titled my-library with the default library structure. Similarly, lein new app my-app will create a project titled my-app using the app directory structure.

Luminus leverages this feature to allow you to mix-and-match different components via specifying different profiles when creating your project. For example,

$ lein new luminus my-luminus-site

... will create a barebones Luminus app with little more than an HTTP server and routing library. Instead, if you wanted libraries for interacting with a PostgreSQL database you could run:

$ lein new luminus my-luminus-site +postgres

... and you'd get a setup including everything you need. For this example, we're going to use PostgreSQL for our database and ClojureScript on the frontend via a tool called shadow-cljs. shadow-cljs lets us use NPM to manage JS dependencies when we need JS interop, and I find it easier to use and set up than the default ClojureScript Granted, I probably haven't given the ecosystem a fair shake. A lot of Clojure build/dependency tooling is very much "here's a few well-designed but minimalist tools, assemble them to your liking" or, as one great blog post put it, an amalgamation of orthogonal parts. I'm very much the opposite (especially when it comes to frontend tooling!) in that I just want a single installable command that will install dependencies reliably and build productionized code painlessly. that uses Leiningen and CLJSJS to manage JS dependencies. To make our project, run:

$ lein new luminus my-luminus-site +postgres +shadow-cljs

Now, if you cd into my-luminus-site, you should see a blank project template.

Setting up Docker Compose

Although we already installed Leiningen locally to create our project, I much prefer to keep each component of the project running in Docker containers for easy and reproducible setup/teardown (and keeping my dev environment clean).

There's three distinct components to our project: the Clojure backend, the ClojureScript frontend, and the Postgres database. Let's create a docker-compose.yml file in our my-luminus-site directory and create those services:

version: "3.9"

services:
  backend:
    image: clojure:lein
    ports:
      - "7000:7000"
      - "3000:3000"
    volumes:
      - ./:/tmp
      - ~/.m2:/root/.m2
      - ~/.lein:/root/.lein
    command: lein run

  frontend:
    image: theasp/clojurescript-nodejs:latest
    ports:
      - "7002:7002"
      - "9630:9630"
    volumes:
      - ./:/tmp
      - ./node_modules/:/tmp/node_modules
      - ~/.m2:/root/.m2
      - ~/.lein:/root/.lein
    command: npx shadow-cljs watch app

  postgres:
    image: postgres:latest
    environment:
      - POSTGRES_USER=db-user
      - POSTGRES_PASSWORD=db-password
    ports:
      - 5342:5342

Let's go through each of these services individually.

...

  backend:
    image: clojure:lein
    ports:
      - "7000:7000"
      - "3000:3000"
    volumes:
      - ./:/tmp
      - ~/.m2:/root/.m2
      - ~/.lein:/root/.lein
    command: lein run
...

For the backend, we're using Clojure's lein image which comes with Leiningen and the JDK preinstalled. Luminus by default exposes port 3000 for HTTP and port 7000 for nREPL, so those need to be exposed. We're mounting the local my-luminus-app directory to the tmp/ directory in the Docker container since that's the default working directory of the Docker image. The ~/.m2 and ~/.lein directories are where our Java JAR dependencies are installed on our host machine. If we didn't mount those, we'd have to reinstall our dependencies every time we restarted the container which would be pretty tedious. The command lein run just launches our app's HTTP server and nREPL.

...

  frontend:
    image: theasp/clojurescript-nodejs:latest
    ports:
      - "7002:7002"
      - "9630:9630"
    volumes:
      - ./:/tmp
      - ./node_modules/:/tmp/node_modules
      - ~/.m2:/root/.m2
      - ~/.lein:/root/.lein
    command: npx shadow-cljs watch app

...

For the frontend, we're using an image that includes all of ClojureScript's dependencies as well as NodeJS (which is needed if we're using NPM with shadow-cljs). Like the backend, we're going to expose nREPL on port 7002 and the live code reload server on port 9630. The mounted volumes are the same as the backend as well - we're just mounting in the current directory and all of our dependencies. Our command launches shadow-cljs' nREPL and live code reloading server.

...

  postgres:
    image: postgres:latest
    environment:
      - POSTGRES_USER=db-user
      - POSTGRES_PASSWORD=db-password
    ports:
      - 5342:5342
...

This one's pretty simple. It's just a Postgres database with a user named db-user and the password db-password. Note: since we aren't mounting the data in a volume, our data won't persist between restarts of the container. If you'd like that feature, just add a volume binding mapping some host directory to /var/lib/postgresql.

Tweaking Configs

Like I mentioned in the intro, Luminus by default doesn't come with a good Docker setup out of the box. We're going to need to tweak a few settings before we can fully use this.

First, we need to change the host binding of shadow-cljs. Since shadow-cljs' nREPL by default only expects messages to come from localhost and we're running it in a Docker container, shadow-cljs will reject any connections we try to make. In shadow-cljs.edn, just add the :host key to the :nrepl map like so:

{ :nrepl {:port 7002
          :host "0.0.0.0"}
...

Also, since we added the +postgres flag, our code expects a database URL in our config somewhere. Our Clojure REPL is similarly picky about where requests come from, so we'll have to bind a new IP address just like for shadow-cljs. In dev-config.edn, add the :database-url and :nrepl-bind keys like so:

{:dev true
 :port 3000
 ;; when :nrepl-port is set the application starts the nREPL server on load
 :nrepl-port 7000
 :nrepl-bind "0.0.0.0"

 ; set your dev database connection URL here
 :database-url "postgresql://db-user:db-password@postgres/"
}

Also, I'm not 100% sure if I just got unlucky and ran into a bug or it was user error, but I noticed a missing dependency by default. If you don't already see it, you'll need to add the cheshire dependency to the project.clj file like so:

...
    :dependencies [[ch.qos.logback/logback-classic "1.2.5"]
                   [cheshire "5.10.1"]
                   [clojure.java-time "0.3.2"]
...

The version numbers might be different and there might be additional dependencies after the time of writing this, but the important thing is to make sure that [cheshire "5.10.1"] line is present.

Running Our Project

Now, if everything is set up properly, we can run the entire project with a single command (be sure you have Docker and Docker Compose installed!):

$ docker-compose up

If you don't care about seeing the logs or don't want to leave the terminal open, you can append the -d option to the above command and everything will run in the background. The containers will probably take a while to spin up, but after a minute or so you should see something like this:

And, if you go to your browser and visit localhost:3000, you should see the following:

Clojure editors/IDEs are outside the scope of this post, but I use Emacs and the fantastic CIDER package to interact with the REPL. You should be able to connect to the backend and frontend with cider-connect-clj and cide-connect-cljs respectively. The backend REPL is on localhost:7000, and the frontend REPL is on localhost:7002.

Packaging as a Single Docker Image

This is a good setup for development, but isn't well suited for deployment. We probably want to use a database not running locally, and things like live code reloading aren't intended for production. Ideally, we'd have a single Docker image that we could run via docker run on a web server. Here's a Dockerfile that builds exactly that:

FROM node:16 as install_npm

WORKDIR /usr/app
COPY ./package.json /usr/app/package.json

RUN npm install

FROM clojure:lein AS build_clj

COPY ./project.clj /app/project.clj
COPY ./shadow-cljs.edn /app/shadow-cljs.edn
COPY ./resources /app/resources
COPY ./src/ /app/src
COPY ./env/prod /app/env/prod

WORKDIR /app

RUN lein uberjar

FROM clojure:latest

COPY --from=build_clj /app/target/uberjar/my-luminus-site.jar .
COPY --from=install_npm /node_modules

CMD java -jar my-luminus-site.jar

If you haven't worked with Docker in a while or haven't gotten past the first few tutorials, this might look different than previous Dockerfiles you've seen. That's because it's a multistage Dockerfile, which (as the name suggests) builds the image artifacts step by step. This lets you use different images for each specific build step and only copy into the image exactly what you need instead of installing every build dependency on one gigantic image. Here's the first step in the above Dockerfile:

FROM node:16 as install_npm

WORKDIR /usr/app
COPY ./package.json /usr/app/package.json

RUN npm install
...
```docker
  
As you can guess, we use the `node` image to install all of our
JS dependencies, as specified in the local `package.json` that we
copy into the working directory of the container. We also alias this step to
`install_npm`, since we'll refer to it later.

```docker
...

FROM clojure:lein AS build_clj

COPY ./project.clj /app/project.clj
COPY ./shadow-cljs.edn /app/shadow-cljs.edn
COPY ./resources /app/resources
COPY ./src/ /app/src
COPY ./env/prod /app/env/prod

WORKDIR /app

RUN lein uberjar

...

This next step uses the clojure:lein image, which has Clojure, its dependencies, and Leiningen preinstalled. We copy over all our config files and source code into the image, and then run lein uberjar to build a single JAR file. This step is also aliased as build_clj for reference later.

...

FROM clojure:latest

COPY --from=build_clj /app/target/uberjar/my-luminus-site.jar .
COPY --from=install_npm /usr/app/node_modules .

CMD java -jar my-luminus-site.jar

This last step is pretty minimal. In this step we don't need Leiningen (since we already built the project), so we can get away with just using the clojure:latest Docker image. It copies over our JAR and node_modules from the previous steps, and runs our JAR file to serve the web app.

Before we build the image, we'll need to add a :database-url key to the my-luminus-site/env/prod/resources/config.edn file. It's just like the dev-config.edn file, but use the production database's connection URL instead I'm not sure why the template doesn't default to also have a blank prod-config.edn, like the dev and test configs. It's not a huge deal as long as you don't accidentally commit the env/resources/prod directory, or if you really want to you can just add a prod-config.end and fiddle with the project.clj as necessary. Regardless, it still feels like a missing stair..

Now, just run the following:

$ docker build -t my-luminus-site .

And then run the Docker image with:

$ docker run --rm -it my-luminus-site:latest

And we're off to the races!

Addendum

While I've worked with Docker and Docker Compose quite a bit, I'm still a novice when it comes to Clojure (especially ClojureScript) and the JVM in general. If I've made any errors here or you have helpful tips/suggestions, please send me an email!