This post walks through a small web development project using Clojure, covering everything from building the app to packaging and deploying it. Itâs a collection of insights and tips Iâve learned from building my Clojure side projects but presented in a more structured format.
As the title suggests, weâll be deploying the app to Fly.io. Itâs a service that allows you to deploy apps packaged as Docker images on lightweight virtual machines. My experience with it has been good, itâs easy to use and quick to set up. One downside of Fly is that it doesnât have a free tier, but if you donât plan on leaving the app deployed, it barely costs anything.
This isnât a tutorial on Clojure, so Iâll assume you already have some familiarity with the language as well as some of its libraries.
In this post, weâll be building a barebones bookmarks manager for the demo app. Users can log in using basic authentication, view all bookmarks, and create a new bookmark. Itâll be a traditional multi-page web app and the data will be stored in a SQLite database.
Hereâs an overview of the projectâs starting directory structure:
.
âââ dev
â âââ user.clj
âââ resources
â âââ config.edn
âââ src
â âââ acme
â âââ main.clj
âââ deps.edn
And the libraries weâre going to use. If you have some Clojure experience or have used Kit, youâre probably already familiar with all the libraries listed below.
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
aero/aero {:mvn/version "1.1.6"}
integrant/integrant {:mvn/version "0.11.0"}
ring/ring-jetty-adapter {:mvn/version "1.12.2"}
metosin/reitit-ring {:mvn/version "0.7.2"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.939"}
org.xerial/sqlite-jdbc {:mvn/version "3.46.1.0"}
hiccup/hiccup {:mvn/version "2.0.0-RC3"}}
:aliases
{:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
integrant/repl {:mvn/version "0.3.3"}}
:main-opts ["-m" "nrepl.cmdline" "--interactive" "--color"]}}}
I use Aero and Integrant for my system configuration (more on this in the next section), Ring with the Jetty adaptor for the web server, Reitit for routing, next.jdbc for database interaction, and Hiccup for rendering HTML. From what Iâve seen, this is a popular âlibrary combinationâ for building web apps in Clojure.
The user
namespace in dev/user.clj
contains helper functions from Integrant-repl to start, stop, and restart the Integrant system.
(ns user
(:require
[acme.main :as main]
[clojure.tools.namespace.repl :as repl]
[integrant.core :as ig]
[integrant.repl :refer [set-prep! go halt reset reset-all]]))
(set-prep!
(fn []
(ig/expand (main/read-config))))
(repl/set-refresh-dirs "src" "resources")
(comment
(go)
(halt)
(reset)
(reset-all))
If youâre new to Integrant or other dependency injection libraries like Component, Iâd suggest reading âHow to Structure a Clojure Webâ. Itâs a great explanation about the reasoning behind these libraries. Like most Clojure apps that use Aero and Integrant, my system configuration lives in a .edn
file. I usually name mine as resources/config.edn
. Hereâs what it looks like:
{:server
{:port #long #or [#env PORT 8080]
:host #or [#env HOST "0.0.0.0"]
:auth {:username #or [#env AUTH_USER "john.doe@email.com"]
:password #or [#env AUTH_PASSWORD "password"]}}
:database
{:dbtype "sqlite"
:dbname #or [#env DB_DATABASE "database.db"]}}
In production, most of these values will be set using environment variables. During local development, the app will use the hard-coded default values. We donât have any sensitive values in our config (e.g., API keys), so itâs fine to commit this file to version control. If there are such values, I usually put them in another file thatâs not tracked by version control and include them in the config file using Aeroâs #include
reader tag.
This config file is then âexpandedâ into the Integrant system map using the expand-key
method:
(ns acme.main
(:require
[aero.core :as aero]
[clojure.java.io :as io]
[integrant.core :as ig]))
(defn read-config
[]
{:system/config (aero/read-config (io/resource "config.edn"))})
(defmethod ig/expand-key :system/config
[_ opts]
(let [{:keys [server database]} opts]
{:server/jetty (assoc server :handler (ig/ref :handler/ring))
:handler/ring {:database (ig/ref :database/sql)
:auth (:auth server)}
:database/sql database}))
The system map is created in code instead of being in the configuration file. This makes refactoring your system simpler as you only need to change this method while leaving the config file (mostly) untouched.
My current approach to Integrant + Aero config files is mostly inspired by the blog post âRethinking Config with Aero & Integrantâ and Laravelâs configuration. The config file follows a similar structure to Laravelâs config files and contains the app configurations without describing the structure of the system. Previously I had a key for each Integrant component, which led to the config file being littered with #ig/ref
and more difficult to refactor.
Also, if you havenât already, start a REPL and connect to it from your editor. Run clj -M:dev
if your editor doesnât automatically start a REPL. Next, weâll implement the init-key
and halt-key!
methods for each of the components:
(ns acme.main
(:require
[acme.handler :as handler]
[acme.util :as util])
[next.jdbc :as jdbc]
[ring.adapter.jetty :as jetty]))
(defmethod ig/init-key :server/jetty
[_ opts]
(let [{:keys [handler port]} opts
jetty-opts (-> opts (dissoc :handler :auth) (assoc :join? false))
server (jetty/run-jetty handler jetty-opts)]
(println "Server started on port " port)
server))
(defmethod ig/halt-key! :server/jetty
[_ server]
(.stop server))
(defmethod ig/init-key :handler/ring
[_ opts]
(handler/handler opts))
(defmethod ig/init-key :database/sql
[_ opts]
(let [datasource (jdbc/get-datasource opts)]
(util/setup-db datasource)
datasource))
The setup-db
function creates the required tables in the database if they donât exist yet. This works fine for database migrations in small projects like this demo app, but for larger projects, consider using libraries such as Migratus (my preferred library) or Ragtime.
(ns acme.util
(:require
[next.jdbc :as jdbc]))
(defn setup-db
[db]
(jdbc/execute-one!
db
["create table if not exists bookmarks (
bookmark_id text primary key not null,
url text not null,
created_at datetime default (unixepoch()) not null
)"]))
For the server handler, letâs start with a simple function that returns a âhi worldâ string.
(ns acme.handler
(:require
[ring.util.response :as res]))
(defn handler
[_opts]
(fn [req]
(res/response "hi world")))
Now all the components are implemented. We can check if the system is working properly by evaluating (reset)
in the user
namespace. This will reload your files and restart the system. You should see this message printed in your REPL:
:reloading (acme.util acme.handler acme.main)
Server started on port 8080
:resumed
If we send a request to http://localhost:8080/
, we should get âhi worldâ as the response:
$
curl localhost:8080/
hi world
Nice! The system is working correctly. In the next section, weâll implement routing and our business logic handlers.
First, letâs set up a ring handler and router using Reitit. We only have one route, the index /
route thatâll handle both GET and POST requests.
(ns acme.handler
(:require
[reitit.ring :as ring]))
(def routes
[["/" {:get index-page
:post index-action}]])
(defn handler
[opts]
(ring/ring-handler
(ring/router routes)
(ring/routes
(ring/redirect-trailing-slash-handler)
(ring/create-resource-handler {:path "/"})
(ring/create-default-handler))))
Weâre including some useful middleware:
redirect-trailing-slash-handler
to resolve routes with trailing slashes,
create-resource-handler
to serve static files, and
create-default-handler
to handle common 40x responses.
If you remember the :handler/ring
from earlier, youâll notice that it has two dependencies, database
and auth
. Currently, theyâre inaccessible to our route handlers. To fix this, we can inject these components into the Ring request map using a middleware function.
(defn components-middleware
[components]
(let [{:keys [database auth]} components]
(fn [handler]
(fn [req]
(handler (assoc req
:db database
:auth auth))))))
The components-middleware
function takes in a map of components and creates a middleware function that âassocsâ each component into the request map. If you have more components such as a Redis cache or a mail service, you can add them here.
Weâll also need a middleware to handle HTTP basic authentication. This middleware will check if the username and password from the request map matche the values in the auth
map injected by components-middleware
. If they match, then the request is authenticated and the user can view the site.
(ns acme.handler
(:require
[acme.util :as util]
[ring.util.response :as res]))
(defn wrap-basic-auth
[handler]
(fn [req]
(let [{:keys [headers auth]} req
{:keys [username password]} auth
authorization (get headers "authorization")
correct-creds (str "Basic " (util/base64-encode
(format "%s:%s" username password)))]
(if (and authorization (= correct-creds authorization))
(handler req)
(-> (res/response "Access Denied")
(res/status 401)
(res/header "WWW-Authenticate" "Basic realm=protected"))))))
A nice feature of Clojure is that interop with the host language is easy. The base64-encode
function is just a thin wrapper over Javaâs Base64.Encoder
:
(ns acme.util
(:import java.util.Base64))
(defn base64-encode
[s]
(.encodeToString (Base64/getEncoder) (.getBytes s)))
Finally, we need to add them to the router. Since weâll be handling form requests later, weâll also bring in Ringâs wrap-params
middleware.
(ns acme.handler
(:require
[ring.middleware.params :refer [wrap-params]]))
(defn handler
[opts]
(ring/ring-handler
{:middleware [(components-middleware opts)
wrap-basic-auth
wrap-params]}))
We now have everything we need to implement the route handlers or the business logic of the app. First, weâll implement the index-page
function which renders a page that:
- Shows all of the userâs bookmarks in the database, and
- Shows a form that allows the user to insert new bookmarks into the database
(ns acme.handler
(:require
[next.jdbc :as jdbc]
[next.jdbc.sql :as sql]))
(defn template
[bookmarks]
[:html
[:head
[:meta {:charset "utf-8"
:name "viewport"
:content "width=device-width, initial-scale=1.0"}]]
[:body
[:h1 "bookmarks"]
[:form {:method "POST"}
[:div
[:label {:for "url"} "url "]
[:input#url {:name "url"
:type "url"
:required true
:placeholer "https://en.wikipedia.org/"}]]
[:button "submit"]]
[:p "your bookmarks:"]
[:ul
(if (empty? bookmarks)
[:li "you don't have any bookmarks"]
(map
(fn [{:keys [url]}]
[:li
[:a {:href url} url]])
bookmarks))]]])
(defn index-page
[req]
(try
(let [bookmarks (sql/query (:db req)
["select * from bookmarks"]
jdbc/unqualified-snake-kebab-opts)]
(util/render (template bookmarks)))
(catch Exception e
(util/server-error e))))
Database queries can sometimes throw exceptions, so itâs good to wrap them in a try-catch block. Iâll also introduce some helper functions:
(ns acme.util
(:require
[hiccup2.core :as h]
[ring.util.response :as res])
(:import java.util.Base64))
(defn preprend-doctype
[s]
(str "<!doctype html>" s))
(defn render
[hiccup]
(-> hiccup h/html str preprend-doctype res/response (res/content-type "text/html")))
(defn server-error
[e]
(println "Caught exception: " e)
(-> (res/response "Internal server error")
(res/status 500)))
render
takes a hiccup form and turns it into a ring response, while server-error
takes an exception, logs it, and returns a 500 response.
Next, weâll implement the index-action
function:
(defn index-action
[req]
(try
(let [{:keys [db form-params]} req
value (get form-params "url")]
(sql/insert! db :bookmarks {:bookmark_id (random-uuid) :url value})
(res/redirect "/" 303))
(catch Exception e
(util/server-error e))))
This is an implementation of a typical post/redirect/get pattern. We get the value from the URL form field, insert a new row in the database with that value, and redirect back to the index page. Again, weâre using a try-catch block to handle possible exceptions from the database query.
That should be all of the code for the controllers. If you reload your REPL and go to http://localhost:8080
, you should see something that looks like this after logging in:
The last thing we need to do is to update the main function to start the system:
(defn -main [& _]
(-> (read-config) ig/expand ig/init))
Now, you should be able to run the app using clj -M -m acme.main
. Thatâs all the code needed for the app. In the next section, weâll package the app into a Docker image to deploy to Fly.
While there are many ways to package a Clojure app, Fly.io specifically requires a Docker image. There are two approaches to doing this:
- Build an uberjar and run it using Java in the container, or
- Load the source code and run it using Clojure in the container
Both are valid approaches. I prefer the first since its only dependency is the JVM. Weâll use the tools.build library to build the uberjar. Check out the official guide for more information on building Clojure programs. Since itâs a library, to use it we can add it to our deps.edn
file with an alias:
{
:aliases
{
:build {:extra-deps {io.github.clojure/tools.build
{:git/tag "v0.10.5" :git/sha "2a21b7a"}}
:ns-default build}}}
Tools.build expects a build.clj
file in the root of the project directory, so weâll need to create that file. This file contains the instructions to build artefacts, which in our case is a single uberjar. There are many great examples of build.clj
files on the web, including from the official documentation. For now, you can copy+paste this file into your project.
(ns build
(:require
[clojure.tools.build.api :as b]))
(def basis (delay (b/create-basis {:project "deps.edn"})))
(def src-dirs ["src" "resources"])
(def class-dir "target/classes")
(defn uber
[_]
(println "Cleaning build directory...")
(b/delete {:path "target"})
(println "Copying files...")
(b/copy-dir {:src-dirs src-dirs
:target-dir class-dir})
(println "Compiling Clojure...")
(b/compile-clj {:basis @basis
:ns-compile '[acme.main]
:class-dir class-dir})
(println "Building Uberjar...")
(b/uber {:basis @basis
:class-dir class-dir
:uber-file "target/standalone.jar"
:main 'acme.main}))
To build the project, run clj -T:build uber
. This will create the uberjar standalone.jar
in the target
directory. The uber
in clj -T:build uber
refers to the uber
function from build.clj
. Since the build system is a Clojure program, you can customise it however you like. If we try to run the uberjar now, weâll get an error:
$
clj -T:build uber
Cleaning build directory...
Copying files...
Compiling Clojure...
Building Uberjar...
$
java -jar target/standalone.jar
Error: Could not find or load main class acme.main
Caused by: java.lang.ClassNotFoundException: acme.main
This error occurred because the Main class that is required by Java isnât built. To fix this, we need to add the :gen-class
directive in our main namespace. This will instruct Clojure to create the Main class from the -main
function.
(ns acme.main
(:gen-class))
If you rebuild the project and run java -jar target/standalone.jar
again, it should work perfectly. Now that we have a working build script, we can write the Dockerfile:
FROM clojure:temurin-21-tools-deps-bookworm-slim AS base
FROM base as build
WORKDIR /opt
COPY . .
RUN clj -T:build uber
FROM eclipse-temurin:21-alpine AS prod
COPY --from=build /opt/target/standalone.jar /
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "standalone.jar"]
Itâs a multi-stage Dockerfile. We use the official Clojure Docker image as the layer to build the uberjar. Once itâs built, we copy it to a smaller Docker image that only contains the Java runtime. By doing this, we get a smaller container image as well as a faster Docker build time because the layers are better cached.
That should be all for packaging the app. We can move on to the deployment now.
First things first, youâll need to install flyctl
, Flyâs CLI tool for interacting with their platform. Create a Fly.io account if you havenât already. Then run fly auth login
to authenticate flyctl
with your account.
Next, weâll need to create a new Fly App:
$
fly app create
? Choose an app name (leave blank to generate one):
automatically selected personal organization: Ryan Martin
New app created: blue-water-6489
Another way to do this is with the fly launch
command, which automates a lot of the app configuration for you. We have some steps to do that are not done by fly launch
, so weâll be configuring the app manually. I also already have a fly.toml
file ready that you can straight away copy to your project.
app = 'blue-water-6489'
primary_region = 'sin'
[env]
DB_DATABASE = "/data/database.db"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[mounts]
source = "data"
destination = "/data"
initial_sie = 1
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
cpus = 1
cpu_kind = "shared"
These are mostly the default configuration values with some additions. Under the [env]
section, weâre setting the SQLite database location to /data/database.db
. The database.db
file itself will be stored in a persistent Fly Volume mounted on the /data
directory. This is specified under the [mounts]
section. Fly Volumes are similar to regular Docker volumes but are designed for Flyâs micro VMs.
Weâll need to set the AUTH_USER
and AUTH_PASSWORD
environment variables too, but not through the fly.toml
file as these are sensitive values. To securely set these credentials with Fly, we can set them as app secrets. Theyâre stored encrypted and will be automatically injected into the app at boot time.
$
fly secrets set AUTH_USER=hi@ryanmartin.me AUTH_PASSWORD=not-so-secure-password
Secrets are staged for the first deployment
With this, the configuration is done and we can deploy the app using fly deploy
:
$
fly deploy
Checking DNS configuration for blue-water-6489.fly.dev
Visit your newly deployed app at https://blue-water-6489.fly.dev/
The first deployment will take longer since itâs building the Docker image for the first time. Subsequent deployments should be faster due to the cached image layers. You can click on the link to view the deployed app, or you can also run fly open
which will do the same thing. Hereâs the app in action:
If you made additional changes to the app or fly.toml
, you can redeploy the app using the same command, fly deploy
. The app is configured to auto stop/start, which helps to cut costs when thereâs not a lot of traffic to the site. If you want to take down the deployment, youâll need to delete the app itself using fly app destroy <your app name>
.
This is an interesting topic in the Clojure community, with varying opinions on whether or not itâs a good idea. Personally I find having a REPL connected to the live app helpful, and I often use it for debugging and running queries on the live database. Since weâre using SQLite, we donât have a database server we can directly connect to, unlike Postgres or MySQL.
If youâre brave, you can even restart the app directly without redeploying from the REPL. You can easily go wrong with it, which is why some prefer to not use it.
For this project, weâre gonna add a socket REPL. Itâs very simple to add (you just need to add a JVM option) and it doesnât require additional dependencies like nREPL. Letâs update the Dockerfile:
EXPOSE 7888
ENTRYPOINT ["java", "-Dclojure.server.repl={:port 7888 :accept clojure.core.server/repl}", "-jar", "standalone.jar"]
The socket REPL will be listening on port 7888. If we redeploy the app now, the REPL will be started but we wonât be able to connect to it. Thatâs because we havenât exposed the service through Fly proxy. We can do this by adding the socket REPL as a service in the [services]
section in fly.toml
.
However, doing this will also expose the REPL port to the public. This means that anyone can connect to your REPL and possibly mess with your app. Instead, what we want to do is to configure the socket REPL as a private service.
By default, all Fly apps in your organisation live in the same private network. This private network, called 6PN, connects the apps in your organisation through Wireguard tunnels (a VPN) using IPv6. Fly private services arenât exposed to the public internet but can be reached from this private network. We can then use Wireguard to connect to this private network to reach our socket REPL.
Fly VMs are also configured with the hostname fly-local-6pn
, which maps to its 6PN address. This is analogous to localhost
, which points to your loopback address 127.0.0.1
. To expose a service to 6PN, all we have to do is bind or serve it to fly-local-6pn
instead of the usual 0.0.0.0
. We have to update the socket REPL options to:
ENTRYPOINT ["java", "-Dclojure.server.repl={:port 7888,:address \"fly-local-6pn\",:accept clojure.core.server/repl}", "-jar", "standalone.jar"]
After redeploying, we can use the fly proxy
command to forward the port from the remote server to our local machine.
$
fly proxy 7888:7888
Proxying local port 7888 to remote [blue-water-6489.internal]:7888
In another shell, run:
$
rlwrap nc localhost 7888
user=>
Now we have a REPL connected to the production app! rlwrap
is used for readline functionality, e.g. up/down arrow keys, vi bindings. Of course you can also connect to it from your editor.
If youâre using GitHub, we can also set up automatic deployments on pushes/PRs with GitHub Actions. All you need is to create the workflow file:
name: Fly Deploy
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
concurrency: deploy-group
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
To get this to work, youâll need to create a deploy token from your appâs dashboard. Then, in your GitHub repo, create a new repository secret called FLY_API_TOKEN
with the value of your deploy token. Now, whenever you push to the main
branch, this workflow will automatically run and deploy your app. You can also manually run the workflow from GitHub because of the workflow_dispatch
option.
As always, all the code is available on GitHub. Originally, this post was just about deploying to Fly.io, but along the way I kept adding on more stuff until it essentially became my version of the user manager example app. Anyway, hope this article provided a good view into web development with Clojure. As a bonus, here are some additional resources on deploying Clojure apps:
Permalink