How to Deploy Your Clojure API with Docker and Fly.io

How to Deploy Your Clojure API with Docker and Fly.io

Background

I recently began a side project in which I needed to deploy a Clojure API.

I couldn't find a detailed guide showing how to do this, so I'm writing one now. Hopefully, this helps anyone who wants to do this in the future.

I chose Fly (fly.io) as my cloud provider because it had a very straightforward developer experience for deploying a service using a Docker container. In addition to your code, you'll need a fly.toml file and a Dockerfile. Then you just run flyctl deploy.

I also chose Fly because there were existing examples of how to configure a Fly application for Clojure. In 2021, a prolific Clojure developer, Borkdude, setup this repository with a complete example of how to deploy a Clojure API on Fly. For my side project, and for this post, I forked, simplified, and updated his repository.

You can find my example repo here. It contains everything you need to deploy a basic Clojure API on Fly.


Overview of code

First, let's go on a quick tour through the repository.

  • fly.toml - contains the configuration for your Fly application, which runs your code inside of a Docker container on one or more virtual machines. The full documentation for this file is here.

  • deps.edn - lists the dependencies used by the Clojure API code. It also contains an :aliases section, which is used in the process of building your API code (more on this later).

  • build.clj - contains Clojure functions used to build a executable file (called an uber JAR) containing your API code and all of its dependencies.

  • Dockerfile - contains instructions for building the Docker image for your API. The documentation for Dockerfile instructions is here.

  • src/acme/app.clj - Clojure code that sets up an HTTP server and defines a single simple endpoint which responds to requests with HTML.

  • .gitignore & .dockerignore - specifies files and directories that should neither be committed to Git nor included in the Docker image during build.

Next, I'll deep dive into each of these files, and explain their contents.


fly.toml

app = "clojure-httpkit-example"
primary_region = "ewr"
kill_signal = "SIGINT"
kill_timeout = "5s"

The app is the name of our Fly application. You can change this to whatever you want. We've set the primary_region to Newark (ewr) but you can change this to one of Fly's other regions. SIGINT is the signal Fly will use to gracefully shut down the process running your code, if necessary. If the process is still running after a kill_timeout of 5s - Fly will stop the virtual machine on which the process is running.

[env]
  PORT = "8080"

The [env] section lists environment variables that will be available to our code while it's running. We use the PORT environment variable in our server config, to define which port our server should listen on. 8080 is the default port that Fly applications use to listen for incoming requests.

[http_service]
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0
  [http_service.concurrency]
    type = "requests"
    soft_limit = 200
    hard_limit = 250

As per Fly's docs, "the [http_service] section defines a service that listens on ports 80 and 443." With force_https, we make sure to redirect any incoming HTTP traffic to HTTPS . To save money, we set auto_stop_machines = true so machines turn off when they aren't being used, and auto_start_machines = true so machines turn back on when they receive a request. I kept min_machines_running = 0 since I don't expect any traffic on this service yet, but in the future, I'll bump the number to 1 or more depending on my expected traffic.

[http_service.concurrency] defines the metric by which Fly decides to start up or shut down virtual machines running our application. This is helpful so we don't keep machines running when they aren't being used. It also allows us can add more machines automatically to handle increases in traffic. We start by handling at most 250 concurrent requests per virtual machine.

[[vm]]
  size = "shared-cpu-1x"
  memory = "256MB"

The [[vm]] section describes the specs of virtual machines your API will run on. The specs given here - shared-cpu-1x with 256MB RAM - are the cheapest listed on Fly's pricing page. You can adjust these as your application requires more resources.


deps.edn

{:deps {org.clojure/clojure {:mvn/version "1.11.1"}
        http-kit/http-kit {:mvn/version "2.7.0"}
        hiccup/hiccup {:mvn/version "2.0.0-RC2"}}

:deps declares three dependencies for our API. We pin the latest version of Clojure with org.clojure/clojure. We use http-kit to setup and run a HTTP server for our API. We use hiccup to build HTML for our API responses.

:aliases {:build
           {:deps {io.github.clojure/tools.build {:git/tag "v0.9.6" :git/sha "8e78bcc"}}
            :ns-default build}}}

:aliases configures how our project gets built. These docs describe how this works in detail. I'll give a high level summary here.

Later, we'll see that our Dockerfile builds our API + dependencies into an uber JAR with the command clojure -T:build uber:

  • clojure is the official Clojure CLI

  • -T:build tells clojure to use the "tool" specified the :build alias to build our program. Within :build:

    • {:deps {io.github.clojure/tools.build {:git/tag "v0.9.6" :git/sha "8e78bcc"}} tells our build step to use a specific version of io.github.clojure/tools.build to build our app

    • :ns-default build tells the clojure CLI to use functions defined in build.clj when building our app.

  • uber uses the uber function found in build.clj to build our source and its dependencies into an uber JAR file.


build.clj

Our build.clj is based on an example give here in the official Clojure docs. Those docs describe the code in detail, so I'll give just a brief summary here. As described above, the uber function in this file is used to build an uber JAR. The function is used by the clojure -T:build command.


Dockerfile

FROM clojure:tools-deps-bookworm-slim AS builder

We use a official Docker image for Clojure as our base image. Specifically, we use clojure:tools-deps-bookworm-slim image. Let's break down what that means:

  • clojure:* - this is the preface that tells Docker to pull from one of the official Clojure images listed here

  • *tools-deps-* - this is part of the tag for any image that is intended to be built using the clojure CLI. It comes with the clojure CLI pre-installed.

  • *-bookworm-slim - this part of the tag specifies the operating system we want to use for our base image. As of writing, bookworm is the most recently released version of Debian. As described here, the *-slim suffix indicates that the base image "only contains the minimal packages needed to run clojure." Since we're only running clojure , slim is all we need.

AS builder allows us to reference files from this image in another image later in our build, as described in these docs. This allows us to use different environments for building and running our code.

WORKDIR /opt

We change the working directory to /opt to build our app. The /opt directory is used in Linux for the installation of "add-on software packages", like the build of our Clojure API.

COPY . .

The first argument to the COPY instruction references a path in our source code repository, while the second argument references a path in the current working directory of our Docker image.

This instruction means, "copy the full contents of our source code into /opt "

RUN clojure -Sdeps '{:mvn/local-repo "./.m2/repository"}' -T:build uber

Next, we RUN the command to build our project. As described in our discussion of the deps.edn file, we run clojure -T:build uber to run the uber function defined in our build.clj file. The uber function builds an uber JAR file from our API source code and its dependencies. The -Sdeps '{:mvn/local-repo "./.m2/repository"}' flag tells clojure to install all dependencies for our API into a subdirectory of our current directory, /opt/.m2/repository.

FROM eclipse-temurin:21-alpine AS runtime

We create another base image in which our API code will run. Breaking down the base image name:

  • eclipse-temurin:* - Previously, OpenJDK was a source of JDK base images on Docker. However, they have since been deprecated, and now recommend eclipse-temurin as a vendor-neutral official source of Open JDK images. Open JDK is an open source implementation of the Java Platform, the host platform for Clojure.

  • *21 specifies that we want to use OpenJDK 21, the latest release of OpenJDK.

This base image is used to build an image called runtime, which does not include anything from our previous image, builder.

COPY --from=builder /opt/target/app.jar /app.jar

Here, we COPY our uber JAR from our builder image (/opt/target/app.jar) to the /app.jar path in our current runtime image.

EXPOSE 8080

We expose port 8080 on our container to match the port exposed by Fly, so our HTTP server can listen for incoming requests.

ENTRYPOINT ["java", "-cp", "app.jar", "clojure.main", "-m", "acme.app"]

ENTRYPOINT tells Docker to run our container as an executable, with the command specified by this array of strings. The array contains the command java -cp app.jar clojure.main -m acme.app. This command runs the JAR we built containing our API code and its dependencies. Breaking it down:

  • java: This is the command to run Java programs.

  • -cp app.jar: This specifies the classpath and tells Java to look for classes in app.jar.

  • clojure.main: This is the Clojure main class, which is the entry point for Clojure programs.

  • -m acme.app: This tells Clojure to load the namespace acme.app and run its -main function.


src/acme/app.clj

Finally, we get to app.clj, where our HTTP server is defined.

(ns acme.app
  (:require [hiccup2.core :refer [html]]
            [org.httpkit.server :as server])
  (:gen-class))

Here, we require dependencies for:

  • our HTTP server (hiccup2.core)

  • building HTML for our API response (httpkit.server)

We include :gen-class to generate a Java Main class with a public static main method from the -main function contained in app.clj when we build our JAR. The main method of this class is then called by the java command when we run our JAR.

(def port (or (some-> (System/getenv "PORT")
                      parse-long)
              8080))

Here, we use either the PORT environment variable defined earlier in our fly.toml to set a var called port. We expect this to be defined as 8080, but just in case, we provide a fallback value of 8080 .

(defn -main [& _args]
  (server/run-server
   (fn [_req]
     {:body
      (str (html
            [:html
             [:body
              [:h1 "Hello world!"]
              [:p (str "This site is running with clojure v"
                       (clojure-version))]]]))})
   {:port port})
  (println "Site running on" (str "http://localhost:" port)))

We setup a server using http-kit's server/run-server function. The server listens on :port and respond to all requests with a :body the HTML string defined using hiccup 's html function and syntax.


With all of the above files in a directory, you should be able to sign up for a Fly account and run flyctl deploy to deploy your API.

If any of the above doesn't work for you, I'd love to know! Leave a comment, or submit a pull request to my repo.