Going to Production

This document lists all the steps to deploy your first app to the interwebz.

Steps

1. Provisioning a server

You need to buy/rent a dedicated VM (Virtual Machine) from a clould or server provider. I like Hetzner as they have very afordable prices.

What server to get

You can start out with a 2VCPU, 8GB RAM and 80GB of storage when first going to production. This should get you through your initial 500 customers depending on your situation.

IMPORTANT: Make sure you have a SSH key set up so you can access the server from your local machine. This will be important also if you want to set up CI/CD deployment.

2. Setting up kamal config

Once you have a server configured go to config/deploy.yml. Here we'll need to do some changes:

2.1 Change service to be the name of your app

-service: shipclojure-datom
+service: my-cool-app

2.2 Change image to be your your-docker-username/image-name.

See creating a private registry for more details

2.3 In servers, replace the IP with the IP of the server you provisioned in step 1.

 # Deploy to these servers.
 servers:
-  - 195.201.24.134
+  - 195.201.12.256 # your server's IP
   # job:

2.4 In registry, replace username stoica94 with your own docker username

2.5 Option A: Configuring a domain

The domain is how people find your app on the internet. Let's say your provisioned server's IP is 195.201.12.256, without a domain, people can still access your app at http://195.201.12.256 but the connection would not be secure and your app would be hard to find. See more details here.

I use cloudflare to buy and provision my domains, but you can use any other domain provider (NameCheap, GoDaddy etc). I recommend cloudflare because you can also enable bot protection, resource caching and see total traffic for your webiste just buying from cloudflare.

2.5.1 Point your domain to your server's IP

From the DNS section of your domain provider, add an A record so that your root domain (@) or a subdomain (app.mydomain.com) points to the IP of your server. Here's a video guide explaining the details.

Example from the shipclojure website: DNS Setup DNS config for shipclojure datom

2.5.2 Once your domain is configured, change it in the proxy section
 # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
 proxy:
   ssl: true
-  host: demo.shipclojure.com
+  host: mycoolsaas.com
   # host: app.example.com
   # Proxy connects to your container on port 80 by default.

2.5 Option B: Disabling the domain

If you just want to deploy something very fast to a pure IP you can just disable ssl and domain hosting and purely deploy to your server and access the app directly on the ip. Here's the change you need

# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
 proxy:
-  ssl: true
-  host: demo.shipclojure.com
+  ssl: false
   # host: app.example.com
   # Proxy connects to your container on port 80 by default.

With this change, kamal won't try to setup ssl certificates to enable a domain. You can always configure a domain later by following the steps from 2.5 Optiona A.

Deployment secrets

Kamal secrets live in .kamal/secrets. This file is safe to commit — it should never contain raw credentials, only references to where they come from (environment variables, a password manager, etc.).

ShipClojure's .kamal/secrets reads everything from environment variables:

ACCESS_TOKEN_SECRET=$ACCESS_TOKEN_SECRET
REFRESH_TOKEN_SECRET=$REFRESH_TOKEN_SECRET
RESEND_API_KEY=$RESEND_API_KEY
GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET
GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID
SESSION_COOKIE_SECRET=$SESSION_COOKIE_SECRET
SESSION_COOKIE_NAME=$SESSION_COOKIE_NAME
STRIPE_API_KEY=$STRIPE_API_KEY
STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET
KAMAL_REGISTRY_TOKEN=$KAMAL_REGISTRY_TOKEN
TOTP_SECRET=$TOTP_SECRET

Before your first deploy, you need to export all of these in your shell (or use a password manager integration like 1Password — see comments in .kamal/secrets):

export KAMAL_REGISTRY_TOKEN=dckr_pat_xxxxxxxxxxxx   # Docker Hub access token
export ACCESS_TOKEN_SECRET=$(openssl rand -hex 32)
export REFRESH_TOKEN_SECRET=$(openssl rand -hex 32)
export SESSION_COOKIE_SECRET=$(openssl rand -hex 32)
export SESSION_COOKIE_NAME=session
export TOTP_SECRET=$(openssl rand -hex 20)
export RESEND_API_KEY=re_xxxxxxxxxxxx
export GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
export GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxx
export STRIPE_API_KEY=sk_live_xxxxxxxxxxxx
export STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx

TIP: Keep these in a local .env file (git-ignored!) and source .env before running kamal commands.

Production secrets

ShipClojure comes with a check for placeholder secrets in production that will throw an error when your application boots up in production. This is a safety measure to avoid issues (that happened to me) in production.

Make sure all secrets listed in config/deploy.yml under env.secret are present in your environment before deploying, or the app will refuse to start.

3. First deployment

Once the config and secrets are in place, run:

kamal setup

kamal setup is the one-time bootstrap command that:

  1. Installs Docker on your server via SSH
  2. Starts the Kamal proxy (Traefik-based reverse proxy that handles SSL and routing)
  3. Pushes and starts all accessories (PostgreSQL, Memcached, Datomic transactor)
  4. Builds and pushes your Docker image
  5. Starts your application container

After kamal setup succeeds, future deploys are done with:

kamal deploy

This builds a new image, pushes it, and performs a rolling zero-downtime restart.

4. Verify the deployment

After deploying, check that everything is running:

# Check app container status
kamal app details

# Tail live logs
kamal logs

# Check accessory status
kamal accessory details db
kamal accessory details memcached
kamal accessory details transactor

You can also open a shell inside the running container:

kamal console

5. Subsequent deploys

Every time you push new code, deploy with:

kamal deploy

The deploy process:

  1. Builds a new Docker image locally (using BuildKit with layer caching)
  2. Pushes the image to your Docker registry
  3. SSHs into the server and starts the new container
  4. Waits for the health check at /health to pass (up to 120 seconds)
  5. Routes traffic to the new container and drains the old one gracefully

NOTE: The boot timeout is set to 120s and the drain timeout to 60s to account for JVM warmup time. See the deploy_timeout, drain_timeout, and boot.wait settings in config/deploy.yml.

Managing accessories

Accessories (PostgreSQL, Memcached, Datomic transactor) are long-running side containers managed by Kamal. Useful commands:

# Start/stop/restart a single accessory
kamal accessory boot db
kamal accessory stop db
kamal accessory reboot db

# Tail accessory logs
kamal datomic-logs        # alias from deploy.yml
kamal accessory logs db --follow

# Open a psql shell into the database
kamal accessory exec db --interactive "psql -U datomic"

IMPORTANT: Accessories store data in named Docker volumes on the server (e.g. datomic_storage_data for PostgreSQL). These volumes persist across deploys and reboots. Do not run kamal accessory reboot db without understanding that it will stop the database temporarily.

Useful aliases

The config/deploy.yml ships with several handy aliases:

AliasWhat it does
kamal consoleOpens an interactive bash shell in the running app container
kamal logsTails the app logs live
kamal gc-logsTails JVM garbage collection logs
kamal heap-analysisLists heap dump files (created on OutOfMemoryError)
kamal datomic-logsTails the Datomic transactor logs
kamal json-logs-tailStreams structured JSON logs (requires jq)
kamal json-logs-errorsShows only error-level log entries
kamal json-logs-latestShows last 100 log entries sorted by timestamp
kamal json-logs-todayShows today's log entries

For a deeper look at how logs are structured, how to write advanced jq queries, request tracing, log retention, and security redaction, see the Observability Guide.

Rollbacks

If a bad deploy goes out, roll back to the previously running image:

kamal rollback

Kamal keeps the previous image tagged and ready. The rollback swaps containers the same way a deploy does — health-checked and zero-downtime.

Cloudflare SSL note

If you're using Cloudflare as your DNS provider (recommended), make sure to set the SSL/TLS encryption mode to "Full" (not "Flexible") in the Cloudflare dashboard. This ensures Cloudflare encrypts traffic all the way to your server. With "Flexible", Cloudflare would only encrypt traffic between the visitor and Cloudflare, leaving your server exposed.

Cloudflare Dashboard → Your domain → SSL/TLS → Overview → Select Full

CI/CD with GitHub Actions

ShipClojure includes a GitHub Actions workflow at .github/workflows/cicd.yml that:

  • Runs lint, format checks, and tests on every push and pull request to main
  • Automatically deploys to production on every push to main (after tests pass)

To enable automated deployments you need to add the following secrets to your GitHub repository (Settings → Secrets and variables → Actions):

SecretDescription
SSH_PRIVATE_KEYPrivate key whose public key is in ~/.ssh/authorized_keys on your server
KAMAL_REGISTRY_TOKENDocker Hub access token
ACCESS_TOKEN_SECRETJWT access token signing secret
REFRESH_TOKEN_SECRETJWT refresh token signing secret
SESSION_COOKIE_SECRETSession cookie encryption secret
SESSION_COOKIE_NAMESession cookie name
TOTP_SECRETTOTP/2FA secret
RESEND_API_KEYResend email API key
GOOGLE_CLIENT_IDGoogle OAuth client ID
GOOGLE_CLIENT_SECRETGoogle OAuth client secret
STRIPE_API_KEYStripe API key
STRIPE_WEBHOOK_SECRETStripe webhook signing secret

See /docs/datom/deployment/ci-cd/ for the full CI/CD setup guide.