anchor

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:

1
We're starting with the official Clojure image with Leiningen installed already. No need to reinvent the wheel.
2
WORKDIR /usr/src/app sets up and switches to our working directory in the container.
3
COPY . . grabs all the files from your project directory and tosses them into the container.
4
RUN lein deps pulls in all your project dependencies.
5
RUN lein uberjar builds your application into a standalone JAR file. This is your whole app, dependencies and all, in one neat package.
6
Finally, we will tell Docker how to run your app using the CMD instructions.

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?

1
docker build tells Docker to construct an image.
2
-t clojure-docker-demo tags our image with a name (feel free to change it).
3
The . at the end tells Docker, "Hey, the Dockerfile is in this directory."

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:

1
We build the app in one container.
2
We copy just the essentials to a new, slimmer container.
3
We toss out the build container. Sayonara, bloat!

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?

1
We've got two FROM instructions. Each one starts a new build stage.
2
The first stage (call it builder) does all the heavy lifting: copying files, resolving deps, and building the uberjar.
3
The second stage starts fresh with just a JRE (no Leiningen, no Clojure compiler, nada).
4
We copy ONLY the uberjar from the builder stage to our final image.

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?

icon
Get creative with your Docker setups. Maybe add some caching? Or throw in an Nginx reverse proxy?
icon
Explore CI/CD pipelines with your newly Dockerized app. Jenkins, GitLab CI, and GitHub Actions are all itching to build your containers.
icon
Explore other Docker features. Volumes, networks, Swarm mode – the rabbit hole goes deep.

Happy coding, and may your containers always be light and your REPLs always be responsive!

Build Your Team
with Freshcode
Author
linkedin
Oleksandr Druk
Clojure Developer

Self-taught developer. Programming languages design enthusiast. 3 years of experience with Clojure.

linkedin
Sofiia Yurkevska
Content Writer

Infodumper, storyteller and linguist in love with programming - what a mixture for your guide to the technology landscape!

Shall we discuss
your idea?
Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
What happens after you fill this form?
We review your inquiry and respond within 24 hours
We hold a discovery call to discuss your needs
We map the delivery flow and manage the paperwork
You receive a tailored budget and timeline estimation
Looking for a Trusted Outsourcing Partner?