Clojure Fiddlings 1

Clojure CLI, Docker, fly.io - Putting it all together

Part of the Clojure Fiddlings series.

I like Clojure. Its beautiful, reduced syntax fascinates me and I’ve been learning it for some time and have meddled with Leiningen, Ring and Compojure a bit. The furthest I got was a Telegram chat bot deployed on Heroku that I was pretty happy with.

It registered as a bot with the API, received callbacks from the Telegram server nicely and forwarded the messages to me as a user. I loved how simple everything was with Compojure and Heroku, but I never really liked Leiningen als a build and dependency tool. I didn’t hate it, but it always felt clunky.

Some time has passed and Clojure got some nice dependency and CLI tools of its own now. Also Heroku has switched off their free tier. Sandra and Daniel recommended fly.io in their fabulous podcast “Ready for Review” so I got curious.

What I want to build

I want to build a URL shortener. I want to POST a URL there and receive a shortened URL back that I can then share on Twitter. If someone calls it, it should return HTTP 302 to the original URL.

The POST calls should be authenticated. DELETE and listing all URLs I’ve saved, maybe exporting them to CSV or EDN are optional features for later.

What I wanted to explore:

  • What’s the minimal amount of tools required to get a simple Clojure webapp into fly.io?
  • How easy is it to run Clojure in Docker?
  • Compared to Leiningen, how does working with the built-in tools feel?
  • How easy is it to get the app into fly.io once I can run the Docker container on my local machine?

Installing Clojure

brew install clojure/tools/clojure ⇒ easy

This installs a larger clojure binary and a smaller clj binary. I think the latter one doesn’t come with the CLI niceties like tab-completion, but I haven’t researched into that much.

Project structure

~/workspaces/clojure/clojure-cli-fly (main ✔) tree
.
├── Dockerfile
├── README.md
├── deps.edn
├── src
│   └── dev
│       └── berky
│           └── links
│               └── core.clj
├── test
│   └── dev
│       └── berky
│           └── links
│               └── core_test.clj
└── tools
    └── tools.edn

Dependencies

You can put your dependencies into a file named deps.edn and clojure will pick them up automatically:

{:deps
 {org.clojure/clojure                 {:mvn/version "1.11.1"}
  http-kit/http-kit                   {:mvn/version "2.3.0"}
  compojure/compojure                 {:mvn/version "1.7.0"}
  clojure.java-time/clojure.java-time {:mvn/version "1.1.0"}}

 :aliases
 {:test {:extra-paths ["test"]
         :extra-deps  {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}}
         :main-opts   ["-m" "cognitect.test-runner"]
         :exec-fn     cognitect.test-runner.api/test}}}

The deps map contains the dependencies I need. I chose http-kit as the web server because it runs without requiring any build magic I never understood before with Leiningen. I can parse the Compojure DSL I’ve used before with the chatbot, so that should be enough.

🤔: I’ve also added a dependency on Clojure itself. Not sure if I still need that. It used to be required in Leiningen before.

The alias test is there to define something like a source tree in Maven but only for test scripts.

I’ve also added a test runner to run all the tests using clj -X:test. There seems to be a new notation for Github dependencies using the SHA of the commit.

Dockerizing

This is the part that took the most fiddling:

FROM clojure:temurin-19-alpine
RUN apk add rlwrap
COPY . /usr/src/app
WORKDIR /usr/src/app
CMD ["clj", "-X", "dev.berky.links.core/main"]

I wanted to use the Alpine base image, for better security on the one hand and also for a faster build on the other hand. There are other official Clojure base images based on a full Ubuntu that worked out of the box, but that didn’t feel like a minimal solution to me.

Without the RUN apk add rlwrap I ran into this error message:

/usr/local/bin/rlwrap: line 13: /usr/bin/rlwrap: not found

I saw the difference in the path pretty quickly but I thought the script was in the wrong location and tried to copy, move or symlink it until was about to give up and use the Ubuntu-based image where it worked.

I opened an interactive shell there:

docker run -it clojure-cli-fly /bin/bash

and checked the file in /usr/bin/rlwrap there, just to find that it’s a binary 🤦🏼‍♂️. The next step was to check the code for the base image to figure out how to install the binary:

apk add rlwrap

Clojure Code

My core.clj looks like this:

(ns dev.berky.links.core
    (:require [compojure.route :as route]
      [compojure.core :refer :all]
      [org.httpkit.server :refer :all]))

(defroutes all-routes
           (GET "/" [] "<h1>Hello World</h1>")
           (route/not-found "<h1>Page not found</h1>"))

(defn main [opts]
      (run-server all-routes {:port 8080}))

Run it with:

docker build -t clojure-cli-fly .
docker run -p 8080:8080 clojure-cli-fly

That’s it for today. I think I got quite far in a few hours today. I’ll post more about deploying to fly.io later.