You know Docker, everybody knows Docker. According to the 2024 Stack Overflow Survey, 59% of devs use Docker, making it the most popular tool in the stack. So yeah, it's a big deal.
Now, let's address the elephant in the room: Docker and Clojure aren't always best pals. Clojure's long startup times and the JVM's memory hunger can make for some chunky containers. Plus, REPL in a containerized environment – it's not exactly a walk in the park.
If you're new to Clojure development and want to containerize your app, you're in the right place. We tried our best to cut through the fluff and get you up and running.
Before we jump in, make sure you've got Docker and Leiningen installed. If not, grab those real quick.
Basic Docker setup for deploying Clojure projects
Create project
Let's create a simple project for demonstration purposes. Here comes the Leiningen you installed earlier.
#+begin_src bash
lein new app clojure-docker-demo
#+end_src
To keep it simple, we’ll make the application print "Hello, Docker!" when run.
#+begin_src clojure
(ns clojure-docker-demo.core
(:gen-class))
(defn -main
[& _args]
(println "Hello, Docker!"))
#+end_src
Create Dockerfile
Now that our Clojure project is up and running, it's time to containerize it. Let's create a Dockerfile to wrap our app in Docker goodness.
Next, we'll create a <span style="font-family: courier new">Dockerfile</span> (no extension) in the root of your project and drop this in:
#+begin_src dockerfile
# Use the official Clojure image with Lein pre-installed as the base image
FROM clojure:lein
# Create and set the working directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
# Copy the project files into the Docker image
COPY . .
# Resolve dependencies
RUN lein deps
# Build the application
RUN lein uberjar
# Set the command to run the application
CMD ["java", "-jar", "target/uberjar/clojure-docker-demo-0.1.0-SNAPSHOT-standalone.jar"]
#+end_src
Let's break this down:
This Dockerfile works, but it's not winning any efficiency awards. We'll optimize it later with multi-stage builds. For now, let's keep it simple. Next up, we'll build this Docker image and take it for a spin!
Building the Docker Image
Now that we have our Dockerfile ready let's build the Docker image:
#+begin_src bash
docker build -t clojure-docker-demo .
#+end_src
What's happening here?
Running the Docker Container
With the image built, you can now run your Clojure application in a Docker container:
#+begin_src bash
# --rm flag tells Docker to remove the container after it stops
docker run --rm clojure-docker-demo
#+end_src
If all goes well, you should see our "Hello, Docker!" message pop up.
Extending Docker setup
While our current setup provides a solid foundation, real-world applications often require more advanced techniques to optimize builds, integrate with databases, and manage multiple services.
Multi-stage builds
Remember how we mentioned Clojure apps can be chunky? By separating the build and runtime environments, multi-stage builds help us slim things down. This is useful for Clojure applications, where you might need to compile your application with Leiningen but only require a JVM to run the compiled JAR file in production. Here's how it goes:
Let's revamp our Dockerfile:
#+begin_src dockerfile
# Stage 1: Build the Clojure application
FROM clojure:lein AS builder
WORKDIR /usr/src/app
COPY . .
RUN lein uberjar
# Stage 2: Run the application in a lightweight environment
FROM openjdk:11-jre-slim
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/target/uberjar/clojure-docker-demo-0.1.0-SNAPSHOT-standalone.jar .
CMD ["java", "-jar", "clojure-docker-demo-0.1.0-SNAPSHOT-standalone.jar"]
#+end_src
What's new?
Integrating with database
Have you seen an app without a database in the wild? We haven't. So, let’s integrate a Clojure application with one inside a Docker container. We’ll use PostgreSQL as an example.
First, we must add the necessary dependencies to <span style="font-family: courier new">project.clj</span>. For example, to connect to PostgreSQL:
#+begin_src clojure
:dependencies [[org.clojure/clojure "1.11.1"]
[org.postgresql/postgresql "42.7.3"]
[org.clojure/java.jdbc "0.7.12"]]
#+end_src
Let's update core.clj to interact with the PostgreSQL database:
#+begin_src clojure
(ns clojure-docker-demo.core
(:gen-class)
(:require [clojure.java.jdbc :as jdbc]))
(def db-spec {:dbtype "postgresql"
:dbname "mydb"
:host "db"
:user "postgres"
:password "password"})
(defn -main
[& _args]
(jdbc/with-db-connection [conn db-spec]
(println "Connected to the database.")
(let [result (jdbc/query conn ["SELECT 'hello from db'"])]
(println "Query result:" result))))
#+end_src
We connect to a PostgreSQL database running in a Docker container. The host is set to db, and the database service's name is defined in our Docker Compose file.
Orchestrating multiple services with Docker Compose
First, we need to create a <span style="font-family: courier new">docker-compose.yml</span> file at the root of our project:
#+begin_src yaml
version: '3.9'
services:
db:
image: postgres
# Set shared memory limit when using docker-compose
shm_size: 128mb
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
app:
build: .
ports:
- "8080:8080"
depends_on:
- db
#+end_src
Here, the <span style="font-family: courier new">db</span> part defines the PostgreSQL service, setting necessary environment variables for the database and mapping the database port inside the container to the host. The </SPAN> part defines our Clojure application service and states the dependency on the <span style="font-family: courier new">db</span> service to the <span style="font-family: courier new">app</span> service waiting for the <span style="font-family: courier new">db</span> to be ready.
Now, we can start both the application and the database using <span style="font-family: courier new">docker-compose</span>:
#+begin_src bash
docker-compose up
#+end_src
Docker Compose will automatically build the image for your Clojure application, start the PostgreSQL database, and link the two services together.
Next, we'll talk about separating your development and production setups because, let's face it, what works in dev doesn't always fly in prod.
Separate Development & Production setup
But what if you want to also have a development setup with access to REPL? Docker allows you to set up development and production environments while tailoring each to its specific needs. We can create separate Dockerfiles and different Docker Compose configurations to define the specifics for development and production.
Development setup
For development, we want fast iteration (no rebuilding images for every code change), REPL access (because who doesn't love a good REPL?), and all the debugging tools we can get our greedy hands on.
Firstly, we'll create a <span style="font-family: courier new">Dockerfile.dev</span> that adds the REPL support.
#+begin_src dockerfile
FROM clojure:lein AS dev
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY . .
RUN lein deps
# Expose the REPL port
EXPOSE 7888
# Command to start the REPL
CMD ["lein", "repl", ":headless", ":host", "0.0.0.0", ":port", "7888"]
#+end_src
And a <span style="font-family: courier new">docker-compose.dev.yml</span> that will use it and map our REPL port.
#+begin_src yaml
version: '3.9'
services:
db:
image: postgres
# Set shared memory limit when using docker-compose
shm_size: 128mb
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "7888:7888" # REPL port
depends_on:
- db
#+end_src
Production setup
We want a slim, efficient image for production. There should be no development tools or source code, and only what's necessary to run the application.
Similarly, create a <span style="font-family: courier new">Dockerfile.prod</span> optimized for production, running only the <span style="font-family: courier new">uberjar</span>.
#+begin_src dockerfile
FROM clojure:lein AS builder
WORKDIR /usr/src/app
COPY . .
RUN lein uberjar
FROM openjdk:11-jre-slim
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/target/uberjar/clojure-docker-demo-0.1.0-SNAPSHOT-standalone.jar .
CMD ["java", "-jar", "clojure-docker-demo-0.1.0-SNAPSHOT-standalone.jar"]
#+end_src
And also docker-compose.prod.yml:
#+begin_src yaml
version: '3.9'
services:
db:
image: postgres:13
# Set shared memory limit when using docker-compose
shm_size: 128mb
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
app:
build:
context: .
dockerfile: Dockerfile.prod
ports:
- "8080:8080"
depends_on:
- db
#+end_src
You can leverage the overriding mechanism provided by docker-compose to avoid maintaining two separate Docker Compose files. Learn more here.
Running the setup
To use the development setup with REPL, run:
#+begin_src bash
docker-compose -f docker-compose.dev.yml up --build
#+end_src
This will start the database and the application, exposing the REPL on port 7888. You can now connect to the REPL from your editor and continue with your development.
For production run:
#+begin_src bash
docker-compose -f docker-compose.prod.yml up --build
#+end_src
And there you have it! You're now running a tight ship with separate dev and prod environments. Your local machine stays clean, your production environment stays lean, and you get to feel like a Docker deity. Not bad for a day's work, eh?
Where from here?
You've become the Docker whisperer for Clojure apps. Need to containerize a Clojure project? You got this. Want to ensure consistent environments across your team? Piece of cake. Looking to simplify your deployment process? It's in the bag. So, yeah, where from here?
Happy coding, and may your containers always be light and your REPLs always be responsive!
with Freshcode