Skip to Content

Production Guide: Deploy Listmonk with Docker Compose + Caddy + PostgreSQL on Ubuntu

A practical single-server Listmonk deployment with HTTPS, PostgreSQL persistence, backups, SMTP hygiene, and recovery checks.

A reliable newsletter platform is often the quiet system behind release notes, incident updates, community announcements, and customer education. Listmonk is a strong fit when a team wants an open-source mailing list manager without committing every subscriber workflow to a hosted marketing suite. This guide deploys Listmonk on a single Ubuntu server with Docker Compose, PostgreSQL, and Caddy, giving you HTTPS, persistent storage, repeatable upgrades, and a backup routine that can be tested before real audiences depend on it.

The deployment below is intentionally conservative. PostgreSQL stays private on the Docker network, Listmonk listens only on localhost, and Caddy is the only public entry point. That keeps the public surface small while still making future operations straightforward. Replace newsletters.example.com with your real hostname before running commands, and make sure DNS points to the server first.

Architecture and flow overview

The public request path is simple: visitor traffic reaches Caddy on ports 80 and 443, Caddy obtains and renews TLS certificates automatically, and then proxies requests to Listmonk on 127.0.0.1:9000. Listmonk stores configuration, subscribers, campaigns, templates, and analytics metadata in PostgreSQL. Uploaded media is kept in a host-mounted directory so it can be backed up separately from the database.

Operationally, this split is useful. You can restart Caddy without touching campaign state, back up the database without scraping container layers, and upgrade Listmonk by changing one image tag after taking a dump. For production, keep SMTP credentials external to the Compose file, monitor both Caddy and Listmonk logs, and test a full restore before sending high-volume campaigns.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with at least 2 vCPU, 2 GB RAM, and 20 GB disk.
  • A DNS record such as newsletters.example.com pointing to the server.
  • Root or sudo access, plus outbound network access for Docker images and ACME validation.
  • An SMTP provider that supports authenticated sending. Do not use an untrusted local mail relay for production campaigns.
  • A backup destination outside the server, such as object storage or another secured host.

Step-by-step deployment

1) Install Docker, Compose, Caddy, and firewall basics

Start with a patched host and expose only SSH, HTTP, and HTTPS. Caddy can run directly on the host, which keeps certificate storage and proxy logs easy to inspect.

sudo apt update
sudo apt install -y ca-certificates curl gnupg ufw
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin caddy
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

If the copy button is unavailable in your browser, manually select and copy the command above.

2) Create the application layout and strong secrets

Keep Listmonk under /opt/listmonk and store generated secrets with restrictive permissions. The secret files are not used directly by Compose after this step, but retaining them makes emergency rotation and audits easier.

sudo mkdir -p /opt/listmonk/{data,postgres,uploads,backups}
sudo chown -R $USER:$USER /opt/listmonk
cd /opt/listmonk
umask 077
openssl rand -base64 32 > .postgres_password
openssl rand -base64 32 > .listmonk_app_secret

If the copy button is unavailable in your browser, manually select and copy the command above.

3) Write environment values

The environment file keeps mutable configuration out of the Compose definition. In a larger environment you may prefer Docker secrets, SOPS, or a vault-backed deployment process. For a single-server setup, a locked-down .env file is acceptable if the host is tightly controlled.

cat > .env <<'EOF'
LISTMONK_DOMAIN=newsletters.example.com
POSTGRES_DB=listmonk
POSTGRES_USER=listmonk
POSTGRES_PASSWORD=replace-with-output-of-.postgres_password
LISTMONK_APP_SECRET=replace-with-output-of-.listmonk_app_secret
TZ=UTC
EOF
sed -i "s|replace-with-output-of-.postgres_password|$(cat .postgres_password)|" .env
sed -i "s|replace-with-output-of-.listmonk_app_secret|$(cat .listmonk_app_secret)|" .env

If the copy button is unavailable in your browser, manually select and copy the command above.

4) Define the Docker Compose stack

This Compose file pins PostgreSQL to a stable major version and makes Listmonk initialization idempotent. The Listmonk container installs and upgrades the schema before starting the web service, which helps after image updates. For stricter change control, replace latest with a tested Listmonk version tag.

cat > compose.yml <<'EOF'
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 10

  listmonk:
    image: listmonk/listmonk:latest
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    env_file: .env
    command: ["sh", "-c", "./listmonk --install --yes --idempotent && ./listmonk --upgrade --yes && ./listmonk"]
    ports:
      - "127.0.0.1:9000:9000"
    volumes:
      - ./uploads:/listmonk/uploads
    environment:
      LISTMONK_app__address: 0.0.0.0:9000
      LISTMONK_db__host: postgres
      LISTMONK_db__port: 5432
      LISTMONK_db__user: ${POSTGRES_USER}
      LISTMONK_db__password: ${POSTGRES_PASSWORD}
      LISTMONK_db__database: ${POSTGRES_DB}
      LISTMONK_app__root: https://${LISTMONK_DOMAIN}
      LISTMONK_app__secret: ${LISTMONK_APP_SECRET}
EOF

If the copy button is unavailable in your browser, manually select and copy the command above.

5) Configure Caddy and start HTTPS

Update the hostname in both .env and the Caddyfile before starting. Caddy must be able to reach Let’s Encrypt over the public internet, and ports 80 and 443 must not be blocked by a cloud firewall.

sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
newsletters.example.com {
  encode zstd gzip
  reverse_proxy 127.0.0.1:9000
  header {
    X-Content-Type-Options nosniff
    X-Frame-Options SAMEORIGIN
    Referrer-Policy strict-origin-when-cross-origin
  }
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
cd /opt/listmonk
docker compose up -d
docker compose logs -f --tail=120 listmonk

If the copy button is unavailable in your browser, manually select and copy the command above.

Configuration and secrets handling best practices

After the service starts, log in to the Listmonk admin interface and configure SMTP. Use a dedicated SMTP credential with the minimum permissions your provider supports. Store SMTP usernames, passwords, and webhook secrets in .env or your secrets manager, not in campaign templates, wiki pages, or shell history. If multiple administrators need access, create individual accounts and avoid shared browser sessions.

Set the public root URL to your HTTPS hostname, configure bounce handling if your SMTP provider supports it, and verify SPF, DKIM, and DMARC before importing a large list. Deliverability problems are easier to prevent than repair. For customer-facing lists, document consent source, retention expectations, and unsubscribe behavior. Listmonk can move quickly, but compliance and trust still depend on your operating model.

For upgrades, take a database dump, read the Listmonk release notes, update the image tag, run docker compose pull, and restart during a quiet window. Do not test a major upgrade for the first time while a campaign is scheduled to send.

Backups and recovery routine

A useful backup captures both PostgreSQL and uploaded assets. The following script keeps local rolling backups; in production, sync the resulting files to off-server storage and run a restore drill monthly.

cat > /opt/listmonk/backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/listmonk
stamp=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p backups
docker compose exec -T postgres pg_dump -U listmonk listmonk | gzip > backups/listmonk-${stamp}.sql.gz
tar -czf backups/listmonk-uploads-${stamp}.tgz uploads
find backups -type f -mtime +14 -delete
EOF
chmod +x /opt/listmonk/backup.sh
sudo tee /etc/cron.d/listmonk-backup >/dev/null <<'EOF'
17 2 * * * root /opt/listmonk/backup.sh >/var/log/listmonk-backup.log 2>&1
EOF

If the copy button is unavailable in your browser, manually select and copy the command above.

To test recovery, provision a disposable host, restore the SQL dump into a fresh PostgreSQL volume, restore uploads, and start Listmonk with sending disabled. Confirm that lists, templates, campaigns, and admin users appear as expected before trusting the backup plan.

Verification checklist

  • DNS resolves to the server and Caddy obtains a valid certificate.
  • The Listmonk login page loads over HTTPS without mixed-content warnings.
  • PostgreSQL is reachable only from the Compose network, not from the public internet.
  • A test SMTP connection succeeds and a small internal campaign is delivered.
  • The backup script creates a compressed SQL dump and upload archive.
  • Logs are clean after restart and there are no repeated migration or database errors.
curl -I https://newsletters.example.com
cd /opt/listmonk
docker compose ps
docker compose exec -T postgres pg_isready -U listmonk -d listmonk
docker compose logs --tail=80 listmonk
sudo journalctl -u caddy --since "15 minutes ago" --no-pager

If the copy button is unavailable in your browser, manually select and copy the command above.

Common issues and fixes

Caddy cannot issue a certificate

Check that the hostname points to the server, ports 80 and 443 are open in both UFW and the cloud firewall, and no other process is bound to those ports. Run sudo journalctl -u caddy for the exact ACME error. DNS propagation delays are common on new records, so verify with a public resolver before changing the stack.

Listmonk starts but the login page returns a proxy error

Confirm that the container is listening on 127.0.0.1:9000 and that Caddy points to the same address. If the container is restarting, inspect docker compose logs listmonk; most first-run failures are database credentials, a typo in .env, or a PostgreSQL volume created with older values.

Campaign email goes to spam

Review SPF, DKIM, DMARC, sending domain alignment, unsubscribe links, and bounce handling. Warm up new domains gradually. A technically healthy Listmonk deployment cannot compensate for a poor sending reputation or a list imported without clear consent.

Uploads disappear after a restart

Make sure uploads are written to the mounted /opt/listmonk/uploads directory and included in backups. If you previously ran Listmonk without the volume mapping, copy assets out of the old container before removing it.

Database backups are empty

Run the backup command manually and check the exit code. If the dump is tiny, verify the database name and user in .env. Also confirm the cron job runs as root or another user with permission to access Docker and write to the backup directory.

FAQ

Can Listmonk replace a full marketing automation suite?

It can replace many newsletter and announcement workflows, especially lists, templates, campaigns, and segmentation. It is not a drop-in replacement for every CRM journey builder, ad platform integration, or proprietary scoring system. Treat it as a focused, self-hosted communication platform.

Should PostgreSQL run on the same server?

For small and midsize deployments, same-host PostgreSQL is simpler and reliable when backed up properly. Move PostgreSQL to a managed service when you need independent scaling, strict recovery objectives, or centralized database operations.

How do I handle SMTP credentials safely?

Use a dedicated provider credential, store it outside the Compose file, rotate it after staff changes, and restrict provider-side permissions where possible. Never paste SMTP secrets into templates or tickets that broad teams can read.

Can I import an existing subscriber list?

Yes, but clean the list first. Remove stale addresses, preserve consent evidence, import in batches, and send a small test campaign before a full migration. Watch bounces and complaints closely during the first sends.

How often should I back up Listmonk?

Daily is usually enough for low-volume newsletters, but increase frequency before large imports or campaign changes. Back up before every upgrade and periodically verify that a restore produces a working admin interface.

What should I monitor first?

Monitor HTTPS availability, container restart count, PostgreSQL disk usage, SMTP failures, bounce rates, and backup freshness. These signals catch the most common operational failures before subscribers notice.

Internal links

Talk to us

If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.

Contact Us

Production Guide: Deploy Wazuh with Docker Compose + Caddy + Single-Node Indexer on Ubuntu
A practical single-node Wazuh SIEM/XDR deployment with HTTPS, agent enrollment, backups, and operational checks.