I wanted to title this “Kamalifying my Listmonk” or “Listmonking my Kamal”, but neither were dirty enough.

Listmonk is an opensource newsletter software that you’ll have to self-host if you don’t want to pay $30+ every month for paid alternatives such as convertkit, mailchimp, aweber etc.

Kamal Deploy is a deployment tool that’s used to deploy containerized web apps over ssh without relying on corporaty things like kubernetes or aws cloud.

A bit more about each.

Listmonk is a Golang webapp and uses postgres db. So it’s fast, lightweight and reliable.
You can use any smtp backend to send the emails. I’m using amazon ses here.

It doesn’t have drip emailing feature though. Meaning, you can’t set up autoresponders. This is a bummer, but I at least can start an email ‘campaign’ every time I want to send an email and write what I want and send to the subscribers.

Also, I got to know about Listmonk only through the amazing Deepakness. His website is a wealth of content if you want to build money-making software.

Deepak deployed listmonk through Dokploy. But that’s one more service you need to have in your server. With Kamal Deploy nothing extra gets added to your server. This post will show you how.

Kamal deploy is a big wrapper around ssh and docker compose. It’s used to deploy any kind of dockerized web app, along with its accessory dependencies (database, redis, elastic search etc).

Since it’s written in ruby, made by DHH, and talked about always in the context of rails, people think it’s only for deploying rails apps. Wrong. It’s a great tool for deploying any containerized/containerizable web app written in any language. It brings back the simplicity of capistrano, the old ssh-based deployment tool that rails devs used in the past until… until Docker and Cloud made everything complex.

Kamal deploy commands are all run from your local system. Those commands are then executed on your server over ssh. The apps and accessories are set up and run as containers in the server. Your regular docker commands all work there (docker ps, docker exect -it, docker logs, docker stop etc)

Kamal has 2 main files. The config/deploy.yml file which defines the kamal configurations. And .kamal/secrets file which has all the secrets and environment variables your app would need. Note that this secrets file should never be checked in to git. So add the line .kamal/secrets to your .gitignore file.

That’s the general intro.

Now I’ll explain my specific architecture for this site’s newsletter… which I think can be used on any simple websites.

Architecture

I’ve pointed the url “list.npras.in” to the listmonk web app. That’s done by creating a cname dns record that points to the server’s ip.

The server is a ubuntu $5/mo 1GB RAM vps.

The requests are handled by kamal proxy. It’s the server/proxy thing that ships with kamal deploy. It’s like the web servers nginx, caddy etc. Except you don’t have to do much to configure it. The defaults work well. It does cool things like setting up Let’s Encrypt https thingy, zero-downtime deployment thingy, health checking the services behind it etc. And this kamal-proxy thing runs as a docker container in the host server.

The listmonk app needs postgresql server. It’s installed as a kamal accessory. It too runs as a container in the same server.

I’ve also set up a container that runs a cron job to periodically take a backup of the pg database and upload it to s3.

In Kamal’s terms, the postgres db and the cron services are accessories. The listmonk webapp is the main ‘web’ service.

Server Hardening

To deploy an app with Kamal, you don’t have to prep your ubuntu server. All it needs is a public ip address and the ability to ssh to it from your local system.

Kamal does its thing as the root user.

While the docs don’t say anything about hardening your server, I think you should do everything in your power to secure your server. Things like locking down ports, keeping the softwares up to date, fixing cve vulnerabilities, disk size monitoring, logging, firewall, ddos management, setting up audit systems etc.

So before giving any server to Kamal, you should harden it.

A good script I found is this one:
https://gist.github.com/rameerez/238927b78f9108a71a77aed34208de11
It’s from https://rameerez.com, specifically this blog post about deploying a rails app with postgres using kamal.

Save it in a file, chmod +x it and then run it as root.

But be sure to understand what it does first. Take an LLM’s help to see if it does what it says it does.

The Project files

So here’s the project’s “tree” for reference, first.

$ tree -a -I '.git'
.
├── config
│   └── deploy.yml
├── cron
│   ├── backup_db
│   └── crontab
├── Dockerfile
├── Dockerfile.cron
├── .gitignore
├── .kamal
│   └── secrets
└── README.md

I’ll explain all the important files here.

Dockerfile

It’s the one responsible for running the listmonk web app in the server. Since Listmonk people regularly publish their docker image to Docker Hub, this file is simple:

FROM listmonk/listmonk:latest

CMD ["/bin/sh", "-c", \
  "./listmonk --install --idempotent --yes --config '' && \
  ./listmonk --upgrade --yes --config '' && \
  exec ./listmonk --config ''"]

As you can see, they’ve set up their app in such a way that re-running the startup command doesn’t do Bad Things accidentally.

Dockerfile.cron

This is the Dockerfile that defines the cron service. It doesn’t use the cron that is widely used with linux. It uses a cron-compatible, cron-like program called Supercronic. The reason is… the default cron doesn’t have access to the environment variables that we are setting through kamal secrets. So we’ll have to do weird things like copy them to a file and then pipe them to crontab via stdin to get it working.. and it doesn’t write to PID 1’s logs etc. Supercronic is much better.

So, the Dockerfile.cron file:

# ─── Stage 1: Download & verify supercronic ───────────────────────────────────
FROM alpine:3.21 AS supercronic-fetcher

ARG SUPERCRONIC_VERSION=0.2.44
ARG SUPERCRONIC_BIN=supercronic-linux-amd64
ARG SUPERCRONIC_SHA1=6eb0a8e1e6673675dc67668c1a9b6409f79c37bc

RUN apk --no-cache add curl \
 && curl -fsSLO "https://github.com/aptible/supercronic/releases/download/v${SUPERCRONIC_VERSION}/${SUPERCRONIC_BIN}" \
 && echo "${SUPERCRONIC_SHA1}  ${SUPERCRONIC_BIN}" | sha1sum -c - \
 && chmod +x "${SUPERCRONIC_BIN}"


# ─── Stage 2: Final image ─────────────────────────────────────────────────────
FROM alpine:3.21

RUN apk --no-cache add \
      aws-cli \
      bash \
      && apk --no-cache add --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \
      postgresql18-client

COPY --from=supercronic-fetcher /supercronic-linux-amd64 /usr/local/bin/supercronic

WORKDIR /cron

COPY --chmod=755 cron/backup_db /cron/backup_db
COPY             cron/crontab   /cron/crontab

ENTRYPOINT ["/usr/local/bin/supercronic"]
CMD ["/cron/crontab"]

For backup, we’d need pg_dump executable.. and also aws cli tool. So install them. Then move the 2 needed files, and then run the supercronic command with the crontab file as its argument.

The crontab file:

0 */3 * * * /cron/backup_db >> /proc/1/fd/1 2>&1
# /proc/1 = PID 1 refers to the main process in any docker container.
# If this cron is run in db accessory, then PID 1 will refer to the pg server process.

# /fd/1 = file descriptor 1. refers to stdout.

# /proc/1/fd/1 = stdout of the process with PID 1.
# doing this so that we can see the log generated by this cron when checking
# the container for its logs with `docker logs <container-id>` or
# `kamal app logs`.

The backup_db bash script:

The backup_db bash script that does the actual backup:

#!/usr/bin/env bash
# script to backup pg db via pg_dump, and upload it to s3.

set -euo pipefail

# script fails if these env vars are not set when being executed.
: "${CRON_POSTGRES_DB:? CRON_POSTGRES_DB is NOT set}"
: "${CRON_POSTGRES_USER:? CRON_POSTGRES_USER is NOT set}"
: "${CRON_POSTGRES_PASSWORD:? CRON_POSTGRES_PASSWORD is NOT set}"
: "${CRON_POSTGRES_HOST:? CRON_POSTGRES_HOST is NOT set}"
: "${CRON_POSTGRES_PORT:? CRON_POSTGRES_PORT is NOT set}"
: "${CRON_AWS_S3_BUCKET:? CRON_AWS_S3_BUCKET is NOT set}"
: "${CRON_AWS_ACCESS_KEY:? CRON_AWS_ACCESS_KEY is NOT set}"
: "${CRON_AWS_SECRET_KEY:? CRON_AWS_SECRET_KEY is NOT set}"

timestamp=$(date +"%Y_%m_%d-%H%M%S")
filename="${CRON_POSTGRES_DB}_${timestamp}.dump"
tmpfile="/tmp/${filename}"

echo "[$(date)] Started: db dump of ${CRON_POSTGRES_DB}..."

PGPASSWORD="${CRON_POSTGRES_PASSWORD}" \
  pg_dump \
  -h "${CRON_POSTGRES_HOST}"  -p "${CRON_POSTGRES_PORT}" \
  -U "${CRON_POSTGRES_USER}" \
  -Fc \
  -d "${CRON_POSTGRES_DB}" \
  -f "${tmpfile}"

echo "[$(date)] Completed: db dump."

s3_path="s3://${CRON_AWS_S3_BUCKET}/backups/${filename}"
echo "[$(date)] Started: uploading db dump to ${s3_path}..."

AWS_ACCESS_KEY_ID="${CRON_AWS_ACCESS_KEY}" AWS_SECRET_ACCESS_KEY="${CRON_AWS_SECRET_KEY}" \
  aws s3 \
  cp "${tmpfile}" "${s3_path}"

echo "[$(date)] Completed: uploading db dump to ${s3_path}."

The .kamal/secrets file

Be careful. This is where you might mention all your passwords and api secrets. So don’t check it into git.

KAMAL_REGISTRY_PASSWORD=$(aws ecr get-login-password --region us-west-1 --profile npras-staging)

APP_NAME_BASE="list_npras_in"

# literal vars
LITERAL_POSTGRES_DB_USER="some_user"
LITERAL_POSTGRES_DB_PASSWORD="very big difficult to guess password"
LITERAL_POSTGRES_DB_PORT="5432"
LITERAL_POSTGRES_DB_HOST="${APP_NAME_BASE}-db"
LITERAL_POSTGRES_DB="listmonk"

# app listmonk secrets
LISTMONK_db__user=$LITERAL_POSTGRES_DB_USER
LISTMONK_db__password=$LITERAL_POSTGRES_DB_PASSWORD
LISTMONK_db__host=$LITERAL_POSTGRES_DB_HOST
LISTMONK_db__port=$LITERAL_POSTGRES_DB_PORT
LISTMONK_db__database=$LITERAL_POSTGRES_DB
LISTMONK_db__ssl_mode="disable"
LISTMONK_app__address="0.0.0.0:9000"
TZ="Etc/UTC"
LISTMONK_ADMIN_USER="admin_user_345"
LISTMONK_ADMIN_PASSWORD="yet another big password"

# accessory postgres secrets
POSTGRES_USER=$LITERAL_POSTGRES_DB_USER
POSTGRES_PASSWORD=$LITERAL_POSTGRES_DB_PASSWORD
POSTGRES_HOST=$LITERAL_POSTGRES_DB_HOST
POSTGRES_PORT=$LITERAL_POSTGRES_DB_PORT
POSTGRES_DB=$LITERAL_POSTGRES_DB

# accessory cron secrets
CRON_POSTGRES_USER=$LITERAL_POSTGRES_DB_USER
CRON_POSTGRES_PASSWORD=$LITERAL_POSTGRES_DB_PASSWORD
CRON_POSTGRES_HOST=$LITERAL_POSTGRES_DB_HOST
CRON_POSTGRES_PORT=$LITERAL_POSTGRES_DB_PORT
CRON_POSTGRES_DB=$LITERAL_POSTGRES_DB
CRON_AWS_ACCESS_KEY="<redacted>"
CRON_AWS_SECRET_KEY="<redacted>"
CRON_AWS_S3_BUCKET="blahblueblay-dbdump-1234552619-us-west-1-en"
CRON_AWS_DEFAULT_REGION="us-west-1"

And finally the kamal config file at config/deploy.yml:

service: my_site
image: my_site/listmonk

servers:
  web:
    hosts:
      - my-ip-address

proxy:
  ssl: true
  host: list.npras.in
  app_port: 9000
  healthcheck:
    path: /health
    interval: 2
    timeout: 30

registry:
  server: 123456789.dkr.ecr.us-west-1.amazonaws.com
  username: AWS
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

env:
  secret:
    - LISTMONK_app__address
    - LISTMONK_db__user
    - LISTMONK_db__password
    - LISTMONK_db__host
    - LISTMONK_db__port
    - LISTMONK_db__database
    - LISTMONK_db__ssl_mode
    - LISTMONK_ADMIN_USER
    - LISTMONK_ADMIN_PASSWORD
    - TZ

volumes:
  - /var/listmonk/uploads:/listmonk/uploads

accessories:
  db:
    image: postgres:18-alpine
    host: list.npras.in
    port: "127.0.0.1:5432:5432"
    env:
      secret:
        - POSTGRES_USER
        - POSTGRES_PASSWORD
        - POSTGRES_HOST
        - POSTGRES_PORT
        - POSTGRES_DB
    directories:
      - data:/var/lib/postgresql
    options:
      health-cmd: "pg_isready -U listmonk"
      health-interval: 10s
      health-timeout: 5s
      health-retries: 6
  cron:
    image: my_site/cron:latest
    host: list.npras.in
    registry:
      server: 123456789.dkr.ecr.us-west-1.amazonaws.com
      username: AWS
      password:
        - KAMAL_REGISTRY_PASSWORD
    env:
      secret:
        - CRON_POSTGRES_USER
        - CRON_POSTGRES_PASSWORD
        - CRON_POSTGRES_HOST
        - CRON_POSTGRES_PORT
        - CRON_POSTGRES_DB
        - CRON_AWS_S3_BUCKET
        - CRON_AWS_ACCESS_KEY
        - CRON_AWS_SECRET_KEY

The Workflow

Since we’re not using any off the shelf Dockerfile for the cron service, we need to build and push its image to ecr.

The commands look like this:

# build image based on Dockerfile.cron:
docker build -f Dockerfile.cron -t my_site/cron .

# tag the recent build as 'latest':
docker tag my_site/cron:latest \
  123456789.dkr.ecr.us-west-1.amazonaws.com/my_site/cron:latest

# push the tagged image to the respective ecr repository:
docker push 123456789.dkr.ecr.us-west-1.amazonaws.com/my_site/cron:latest

Now, let’s setup the 2 accessories - the postgres db server and the cron service - in the server.

Run this locally from project root:

kamal accessory boot db
kamal accessory boot cron

db and cron are the names we gave for the accessories in kamal deploy config file above.

If for any reason, you want to change anything in these accessories… like wanting to change the cron timing schedule, changing postgres’ password etc.. you can reboot the respective accessory, or remove and reboot in certain cases.

kamal accessory reboot db
kamal accessory reboot cron

Be careful, this might delete db data.

Now check if the accessories are set up in the server by ssh’ing into the server and checking docker ps first. Then, with their container ids, you can run docker logs <id> -f or docker exec -it <id> '/bin/sh', or even some aspect of docker inspect <id> ....

You should see them up and running fine and dandy.

Now that the accessories are up and running, it’s time to deploy the main app.

No big ceremony. Just run kamal deploy locally.

After some colorful log output in stdout, you should see some success message.

If you didn’t.. then bad luck. But not for long, ssh into the server and check.. and take LLM’s help and do some weird shit until you or the server yields over the relentless force called The Time.