How to deploy a Datomic-backed Clojure SaaS using Kamal

By Ovi StoicaOct 14, 2025

Clojure is a great language to write your SaaS (Software as a Service) with, especially if you plan to maintain this product long-term as Clojure code is very stable. If you pair this with Datomic you get unbelievable leverage when it comes to complex data models and you fully do without Object–relational impedance mismatch. Datomic is a joy to work with but there is an initial hurdle to get started with it, particularly around deployment.

Datomic deployment options

Datomic supports multiple deployment targets. The recommended way of getting started is with datomic cloud ions where the storage behind Datomic is AWS Dynamo DB

Since Datomic was initially released for the AWS cloud, it still is the de-facto deployment target for it, BUT if you'd like to deploy to your own servers, it starts to be quite complicated as Datomic is a bit more involved than your normal SQL DB where you just need to start the DB server and you are pretty much done.

There is an entire thread on the topic of deploying a datomic backed SaaS in the clojurians slack if you want to look at a more in depth discussion.

What are the pieces involved in deploying a Datomic DB

The main advantage of Datomic is that it separates the responsibilities for reading and writing to different processes. The writing is done by a Transactor and reading (querying data) is done through a Peer.

The transactor is responsible for writing data in a ACID manner and the Peer is responsible for querying the data. The peer can be part of your application, so you can start it when you start your own application but the transactor needs to be separate. On top of this you need to also think about storage.

Datomic supports multiple storage targets:

  • DynamoDB (DDB)
  • Traditional SQL Databases (PostgreSQL, MySQL etc)
  • Memory (You'd use this for local development or testing)
  • ... and even files on the filesystem

On top of that, there is also caching that can be placed between the Peer and the Storage target to reduce the load on Storage. This part can be handled with Memcached.

Ok, so let's recap. To deploy a Datomic storage you need:

  1. A Transactor process (separate from your app)
  2. A Peer process (can be part of your app)
  3. A Storage - In this article we'll use PostgreSQL
  4. (Optional) - A cache layer between the Peer and Storage so the Peer tries to get data from the cache before going to hard Storage.

Enter Kamal

Kamal is a tool to help you deploy dockerized applications to any server you own. It handles installing Docker, certificates and supports 0-downtime deploys to your servers. It is a great tool if you deploy to your own servers.

Let's explore a simple example. Let's say I'll deploy a simple clojure app that just returns "Hello World" when I visit the address:

(ns simple-server
  (:require [org.httpkit.server :as server]))

(defn hello-world-handler
  "Simple handler that returns Hello World for any request"
  [request]
  {:status 200
   :headers {"Content-Type" "text/plain"}
   :body "Hello World"})

(defn start-server
  "Start the HTTP server on the specified port (default 3000)"
  [port]
   (println (str "Starting server on port " port))
   (server/run-server hello-world-handler {:port port}))

(defn -main
  "Main function to start the server"
  [& args]
  (let [port (if (seq args)
               (Integer/parseInt (first args))
               3000)]
    (start-server port)))

Here's a Dockerfile for this simple server:

FROM clojure:temurin-21-lein-alpine

# Set working directory
WORKDIR /app

# Copy dependencies file
COPY deps.edn .

# Copy source code
COPY src/ src/

# Expose port
EXPOSE 3000

# Run the server
CMD ["clojure", "-M", "-m", "simple-server"]

Let's explore a very simple kamal config:

service: hello-world-app
image: ovistoica/hello-world-app
servers:
  - 192.168.0.1
registry:
  username: registry-user-name
  password:
    - KAMAL_REGISTRY_PASSWORD
builder:
  arch: amd64
  • service is the name of our app. Here it will be hello-world-app
  • image represents the name of the built Docker image, when pushed to a Docker registry. When Kamal deploys an app, it will SSH into the server, pull the latest image of that name and run it. You can start by using the official Docker Registry as it gives you one private image. Be careful not to make your images public.
  • servers are the list of IPs of the servers where you'll deploy your app. For now we'll deploy to the same server.
  • registry contains the login information for Kamal to log in to the registry, push & pull the new images for deployment
  • builder refers to the architecture of the builder used. This item is here to support remote builders.

With all of this in place, we can just run the initial setup:

kamal setup

Kamal Accesories

Ok, so we got the basics of Kamal, but how do we get Datomic into this setup? The answer is Accessories. Accessories are helper processes that you can boot along (or before) your main application in order for it to run. An accessory can be any process, but generally it is a database service, a Redis server or any other auxiliary to your main application process.

Defining Accessories

You can define an accessory as part of your Kamal config. Let's say we'd want to add a PostgreSQL database to our hello world app. Here's how that config would look:

service: hello-world-app
# rest of config

# Environment variables for the app
env:
  clear:
    # notice the hello-world-app-db domain name
    DB_URI: "jdbc:postgresql://hello-world-app-db:5432/postgres?user=postgres&password=postgres"

accessories:
  # PostgreSQL database
  db:
    image: postgres:17.5-bookworm
    host: 192.168.0.1 # same server as our app
    # Make port available only to localhost not public to the internet
    port: "127.0.0.1:5432:5432"
    env:
      clear:
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: postgres
        POSTGRES_DB: postgres
    files:
      # script to initiate tables if needed
      - config/init.sql:/docker-entrypoint-initdb.d/setup.sql
    directories:
      # make storage persistent
      - storage_data:/var/lib/postgresql/data

Notice the DB_URI environment variable: jdbc:postgresql://hello-world-app-db:5432/postgres?user=postgres&password=postgres. Kamal enables pointing to the Docker container of the accessory with the name {SERVICE_NAME}-{ACCESSORY-NAME} so our db is accessible at hello-world-app-db.

Deploying a Clojure Datomic app

Ok. Let's look at a real world scenario of deploying an clojure SaaS with Datomic.

The app code

The peer will be part of our app so our datomic app looks like this:

(ns datomic-saas.core
  (:require
   [datomic.api :as d]
   [org.httpkit.server :as server]
   [cheshire.core :as json]
   [clojure.java.io :as io]))

;; Simple schema for a user entity
(def schema
  [{:db/ident :user/id
    :db/valueType :db.type/uuid
    :db/cardinality :db.cardinality/one
    :db/unique :db.unique/identity}
   {:db/ident :user/name
    :db/valueType :db.type/string
    :db/cardinality :db.cardinality/one}
   {:db/ident :user/email
    :db/valueType :db.type/string
    :db/cardinality :db.cardinality/one
    :db/unique :db.unique/identity}])

(defn init-db
  "Initialize database connection and schema"
  [db-uri]
  (d/create-database db-uri)
  (let [conn (d/connect db-uri)]
    @(d/transact conn schema)
    conn))

(defn get-all-users
  "Query to get all users from the database"
  [db]
  (d/q '[:find ?name ?email
         :where
         [?e :user/name ?name]
         [?e :user/email ?email]]
       db))

(defn users-handler
  "HTTP handler to return all users as JSON"
  [conn request]
  (let [db (d/db conn)
        users (get-all-users db)
        user-maps (map (fn [[name email]]
                        {:name name :email email})
                      users)]
    {:status 200
     :headers {"Content-Type" "application/json"}
     :body (json/generate-string {:users user-maps})}))

(defn health-handler
  "Simple health check endpoint"
  [request]
  {:status 200
   :headers {"Content-Type" "application/json"}
   :body (json/generate-string {:status "healthy"})})

(defn app-handler
  "Main application router"
  [conn]
  (fn [request]
    (case (:uri request)
      "/health" (health-handler request)
      "/users" (users-handler conn request)
      {:status 404
       :headers {"Content-Type" "application/json"}
       :body (json/generate-string {:error "Not found"})})))

(defn start-server
  "Start the HTTP server with Datomic connection"
  [port db-uri]
  (println (str "Initializing Datomic connection to: " db-uri))
  (let [conn (init-db db-uri)]
    (println (str "Starting server on port " port))
    (server/run-server (app-handler conn) {:port port})))

(defn -main
  "Main function to start the Datomic SaaS server"
  [& args]
  (let [port (Integer/parseInt (or (System/getenv "PORT") "3000"))
        db-uri (or (System/getenv "DATOMIC_URI")
                   "datomic:sql://myapp?jdbc:postgresql://localhost:5432/datomic?user=datomic&password=datomic")]
    (start-server port db-uri)))

Datomic Kamal config

Ok, now that we understand accessories, let's look at defining all the components required for running a Datomic database. We will have 3 accessories:

  1. Datomic transactor
  2. PostgreSQL for Datomic storage
  3. Memcached server for cache
accessories:
  # PostgreSQL database - Used for Datomic storage
  db:
    image: postgres:17.5-bookworm
    host: 192.168.0.1 # these are all on the same server but you can specify different servers.
    # Make port available only to localhost not public to the internet
    port: "127.0.0.1:5432:5432"
    env:
      clear:
        POSTGRES_USER: datomic
        POSTGRES_PASSWORD: datomic
        POSTGRES_DB: datomic
    files:
      - config/init.sql:/docker-entrypoint-initdb.d/setup.sql
    directories:
      - datomic_storage_data:/var/lib/postgresql/data

  # Memcached - equivalent to your memcached service
  memcached:
    image: memcached:1.6-bookworm
    host: 192.168.0.1
    port: "127.0.0.1:11211:11211"  # Only accessible locally

  # Datomic Transactor - equivalent to your transactor service
  transactor:
    image: stoica94/datomic-pro-clj-1-12-bookworm-slim:1.0.7364 # prebuilt version of the image
    host: 192.168.0.1
    port: "127.0.0.1:4334:4334"  # Only accessible locally for peer connections
    cmd: "/opt/datomic/bin/transactor /opt/datomic/config/transactor.properties"
    files:
      - config/sql-transactor.properties:/opt/datomic/config/transactor.properties
    directories:
      - datomic_transactor_logs:/opt/datomic/logs

Auxiliary config files

In the accessory config from above, you see reference to some other config files used. Here they are:

Transactor properties

sql-transactor.properties
protocol=sql
host=hello-world-app-transactor
port=4334

# See https://docs.datomic.com/on-prem/storage.html
sql-url=jdbc:postgresql://hello-world-app-db:5432/datomic
sql-user=datomic
sql-password=datomic

## The Postgres driver is included with Datomic. For other SQL
## databases, you will need to install the driver on the
## transactor classpath, by copying the file into lib/,
## and place the driver on your peer's classpath.
sql-driver-class=org.postgresql.Driver

###################################################################
# See https://docs.datomic.com/on-prem/capacity.html
## Recommended settings for -Xmx4g production usage.
memory-index-threshold=32m
memory-index-max=512m
object-cache-max=1g

As pointed out earlier, the hostname is in the format {SERVICE_NAME}-{ACCESSORY} (hello-world-app-transactor, hello-world-app-db). Also note that we specify for the transactor the address and credentials for the storage in the same format.

init.sql

Create the Datomic storage table:

init.sql
CREATE TABLE datomic_kvs
(
id text NOT NULL,
rev integer,
map text,
val bytea,
CONSTRAINT pk_id PRIMARY KEY (id )
)
WITH (
OIDS=FALSE
);
ALTER TABLE datomic_kvs
OWNER TO datomic;
GRANT ALL ON TABLE datomic_kvs TO datomic;
GRANT ALL ON TABLE datomic_kvs TO public;

The Datomic Pro Docker image

You can create your own but for reference here's what I use:

Dockerfile.transactor
FROM --platform=linux/amd64 clojure:tools-deps-1.12.1.1550-bookworm-slim AS builder

# # Install system dependencies in a single layer
RUN apt-get update  \
    && apt-get -y --no-install-recommends install  \
    curl unzip ca-certificates build-essential \
    && rm -rf /var/lib/apt/lists/*

ARG DATOMIC_VERSION=1.0.7364

WORKDIR /opt/

# # Download and extract Datomic
RUN curl -O https://datomic-pro-downloads.s3.amazonaws.com/${DATOMIC_VERSION}/datomic-pro-${DATOMIC_VERSION}.zip && \
    unzip datomic-pro-${DATOMIC_VERSION}.zip && \
    rm datomic-pro-${DATOMIC_VERSION}.zip && \
    mv datomic-pro-${DATOMIC_VERSION} datomic


ENV JVM_OPTS="-Xmx1g -Xms1g"

CMD ["/opt/datomic/bin/transactor $JVM_OPTS /opt/datomic/config/transactor.properties"]

Deploying Multiple Datomic Apps to the Same Server

You can skip this section if you only intend to deploy one app per server.

One of the great benefits of Kamal is the ability to deploy multiple applications to the same server. However, there are some important caveats to understand about Docker networking and port mappings.

Understanding Docker Port Mappings

When you specify a port mapping in Kamal like "127.0.0.1:5433:5432", the format is:

HOST_IP:HOST_PORT:CONTAINER_PORT
  • HOST_PORT: The port on your server that you can access externally (e.g., when you SSH in)
  • CONTAINER_PORT: The port the service actually runs on inside the Docker container

This is a crucial distinction because containers communicate with each other using Docker's internal network, not through the host ports.

Internal vs External Communication

Internal Communication (Container-to-Container)

When your application container talks to your database container, it uses:

  • Docker's internal network hostnames (e.g., shipclojure-website-db)
  • The standard container ports (PostgreSQL: 5432, Memcached: 11211, etc.)
  • No host port mapping is involved

Example connection string:

datomic:sql://datomic?jdbc:postgresql://shipclojure-website-db:5432/datomic

External Access (SSH → Server)

The host port mappings like 127.0.0.1:5433:5432 are only for:

  • Accessing services when you SSH into the server
  • Debugging and maintenance from the host machine
  • Host-based monitoring tools

The 127.0.0.1 binding ensures the port is:

  • ✅ Accessible from localhost on the VM (when you SSH in)
  • Not accessible from the public internet
  • Not accessible from other machines

Common Pitfall (aka mistakes I made): Incorrect Port Mappings

A common mistake when deploying multiple apps is mapping ports incorrectly. For example:

❌ WRONG:

# First app uses standard ports
db:
  port: "127.0.0.1:5432:5432"

# Second app tries to use different ports everywhere
db:
  port: "127.0.0.1:5433:5433"  # WRONG!

With the above configuration, your second app's connection string would fail:

env:
  clear:
    # This will fail because PostgreSQL runs on 5432 inside the container, not 5433!
    DATOMIC_DB_URI: "datomic:sql://datomic?jdbc:postgresql://my-app-db:5433/datomic?..."

This results in connection errors like:

java.net.UnknownHostException: my-app-db
org.postgresql.util.PSQLException: The connection attempt failed.

✅ CORRECT:

# First app
accessories:
  db:
    port: "127.0.0.1:5432:5432"

# Second app - different HOST port, same CONTAINER port
accessories:
  db:
    port: "127.0.0.1:5433:5432"  # Host 5433 → Container 5432

Connection string for second app:

env:
  clear:
    # Uses standard container port 5432
    DATOMIC_DB_URI: "datomic:sql://datomic?jdbc:postgresql://my-app-db:5432/datomic?..."

Complete Example: Two Apps on One Server

Here's how to configure two Datomic apps on the same server:

First App (shipclojure-datom):

service: shipclojure-datom
servers:
  - 198.51.100.10

env:
  clear:
    DATOMIC_DB_URI: "datomic:sql://datomic?jdbc:postgresql://shipclojure-datom-db:5432/datomic?user=datomic&password=datomic"
    JAVA_TOOL_OPTIONS: "-Ddatomic.memcachedServers=shipclojure-datom-memcached:11211"

accessories:
  db:
    image: postgres:17.5-bookworm
    port: "127.0.0.1:5432:5432"
    directories:
      - datomic_storage_data:/var/lib/postgresql/data

  memcached:
    image: memcached:1.6-bookworm
    port: "127.0.0.1:11211:11211"

  transactor:
    image: stoica94/datomic-pro-clj-1-12-bookworm-slim:1.0.7364
    port: "127.0.0.1:4334:4334"
    files:
      - config/sql-transactor.properties:/opt/datomic/config/transactor.properties

Second App (shipclojure-website):

service: shipclojure-website
servers:
  - 198.51.100.10

env:
  clear:
    # Same container ports, different hostnames
    DATOMIC_DB_URI: "datomic:sql://datomic?jdbc:postgresql://shipclojure-website-db:5432/datomic?user=datomic&password=datomic"
    JAVA_TOOL_OPTIONS: "-Ddatomic.memcachedServers=shipclojure-website-memcached:11211"

accessories:
  db:
    image: postgres:17.5-bookworm
    port: "127.0.0.1:5433:5432"  # Different host port!
    directories:
      - datomic_website_storage_data:/var/lib/postgresql/data  # Different volume!

  memcached:
    image: memcached:1.6-bookworm
    port: "127.0.0.1:11212:11211"  # Different host port!

  transactor:
    image: stoica94/datomic-pro-clj-1-12-bookworm-slim:1.0.7364
    port: "127.0.0.1:4335:4334"  # Different host port!
    files:
      - config/sql-transactor.properties:/opt/datomic/config/transactor.properties

Transactor Properties for Second App:

protocol=sql
host=shipclojure-website-transactor
port=4334  # Container port, not host port!

sql-url=jdbc:postgresql://shipclojure-website-db:5432/datomic  # Container port!
sql-user=datomic
sql-password=datomic
sql-driver-class=org.postgresql.Driver

Key Takeaways for Multiple Apps

  1. Host ports must be unique across all apps on the same server (5432 vs 5433, 11211 vs 11212, etc.)
  2. Container ports stay standard for internal communication (PostgreSQL: 5432, Memcached: 11211, Transactor: 4334)
  3. Connection strings always use container ports, not host ports
  4. Directory volumes must be unique to prevent data conflicts between apps
  5. Service names create unique hostnames in Docker's network (e.g., {service}-db)

With these principles in mind, you can deploy as many Datomic-backed applications as you want to a single server without conflicts!

General Troubleshooting

Accessories aren't deployed correctly

This setup needs the accessories to be present when the service itself is deployed. You can ensure all accessories are deployed beforehand. Once they are deployed and the containers are running, they won't be redeployed, unless you are doing upgrades.

Full accessory reboot
# Remove bad state accessories
kamal accessory remove db
kamal accessory remove transactor
kamal accessory remove memcached

kamal accessory boot db #db first as transactor depends on it
kamal accessory boot transactor #boot transactor

kamal accessory logs transactor #ensure transactor booted
# You should see something like:
#2025-10-04T05:50:21.106086404Z Launching with Java options -server -Xms1g -Xmx1g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
#2025-10-04T05:50:24.510985657Z System started

kamal accessory boot memcached #boot memcached

# now you can safely do
kamal deploy

On subsequent deploys, your accesories will already be booted so you don't have to do this incantantion everytime but it's good to know it, especially for initial deploys.

Code repo

I added all of the code we discussed in this blog post to this repository: kamal-datomic-clojure-example so you have the full overview.

Conclusion

This was a full guide on how to deploy a Kamal backed Clojure SaaS using Datomic to your own servers. This is the exact setup used in ShipClojure Datom and while it is a bit complicated to set up initially, it is seamless to use afterwards, being useful for CI/CD automated deploys, 0 downtime upgrades and scaling to new servers if needed.

I hope you enjoyed this article!