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
tellsclojure
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 ofio.github.clojure/tools.build
to build our app:ns-default build
tells theclojure
CLI to use functions defined inbuild.clj
when building our app.
uber
uses theuber
function found inbuild.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 theclojure
CLI. It comes with theclojure
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 runclojure
." Since we're only runningclojure
, 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 recommendeclipse-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 inapp.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 namespaceacme.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.
Subscribe to my newsletter
Read articles from Liam Duffy directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Liam Duffy
Liam Duffy
Hello, world! My name is Liam. I'm a software engineer from Brooklyn, NY. I've built full-stack web apps for several early-stage startups. I was one of the first engineers at Flowcode, a QR-code builder used by most of the Fortune 1000. Before that, I was on the founding team of Social Star, a volunteering platform for one of India's largest non-profits. I built and launched the first version of The Juggernaut, a Y-Combinator-backed publication for South Asian journalism. I'm currently looking for a new team to join! If you're hiring, reach out, you can find my contact info on my resume - liam.fyi/resume.pdf