SaaS email marketing platforms charge a fortune as your subscriber list grows. If you have 50,000 subscribers, you are easily looking at $300 to $500 every single month just to send a few newsletters. Today, we will self-host Listmonk using Docker Compose on a cheap VPS to send millions of emails for the price of your raw SMTP usage.

Why I Ditched SaaS Email Providers for Listmonk
I used to pay hundreds of dollars a month for bloated newsletter software that did half of what I actually needed. Listmonk is different. It is a single, compiled Go binary that uses PostgreSQL as its backend. It is insanely fast, easily pushing thousands of emails per second without breaking a sweat or hogging your system resources.
Unlike heavy PHP alternatives, Listmonk is built for raw performance. I previously wrote about how to self-host Mautic with Docker Compose, which is great for complex marketing automation. However, if your primary goal is newsletters, transactional transactional emails, and straightforward list management, Listmonk is much lighter, faster, and simpler to maintain.
By self-hosting, you retain complete ownership of your subscriber data. There are no arbitrary limits on how many lists you can create, how many custom fields you can track, or how many campaigns you can run. You only pay for your underlying server and your SMTP relay provider.
Prerequisites for Your Setup
Before writing configuration files, you need a few basic things ready. Do not skip these, or you will end up troubleshooting network and database connection errors later.
- A Linux VPS (Ubuntu 22.04 LTS or 24.04 LTS is perfect) with at least 1GB of RAM and 1 CPU core.
- Docker and Docker Compose installed on your server. You can check the official Docker Compose documentation for installation steps.
- A domain or subdomain (e.g., newsletter.yourdomain.com) with an A record pointing to your VPS IP address.
- An SMTP relay service like Amazon SES, Postmark, or Mailgun. Listmonk does not send emails directly; it routes them through your SMTP provider.
Prepare the Directory and Environment Variables
First, SSH into your server and create a dedicated directory for your Listmonk stack. Keeping your configuration files organized makes updates and backups much easier down the road.
Run the following commands to create the directory structure and change into it:
mkdir -p ~/listmonk/data
cd ~/listmonk
Next, we need to create a configuration file for Listmonk. Listmonk can read its settings from a config.toml file. We will create this file in our directory to store our database credentials and application settings.
Create the config.toml file using your favorite editor:
nano config.toml
Paste the following configuration into your config.toml file. Make sure to change the admin_username, admin_password, and the database passwords to your own secure strings.
[app]
address = "0.0.0.0:9000"
admin_username = "admin"
admin_password = "ChangeThisSecurePassword123!" [db]
host = "db"
port = 5432
user = "listmonk"
password = "SuperSecureDBPassword987!"
sslmode = "disable"
name = "listmonk" [db.pool]
max_conns = 10
min_conns = 2
max_conn_lifetime = "300s"
max_conn_idle_time = "15s"
This configuration instructs Listmonk to listen on port 9000 and connect to a PostgreSQL database hosted at the hostname db. Since both containers will run inside the same Docker network, they can communicate securely without exposing the database port to the public internet.
Create the Docker Compose Configuration
Now we will write the docker-compose.yml file. This file defines two services: the PostgreSQL database and the Listmonk application itself. We will use the official images for both.
Create the file in your terminal:
nano docker-compose.yml
Paste the following configuration into the file:
version: '3.7' services: db: image: postgres:15-alpine container_name: listmonk_db restart: unless-stopped environment: POSTGRES_USER: listmonk POSTGRES_PASSWORD: SuperSecureDBPassword987! POSTGRES_DB: listmonk volumes: - ./data:/var/lib/postgresql/data networks: - listmonk_net app: image: listmonk/listmonk:latest container_name: listmonk_app restart: unless-stopped ports: - "127.0.0.1:9000:9000" volumes: - ./config.toml:/listmonk/config.toml depends_on: - db networks: - listmonk_net networks: listmonk_net: driver: bridge
Notice that we bound the application port to 127.0.0.1:9000 instead of exposing it to 0.0.0.0:9000. This prevents anyone from accessing your Listmonk installation directly via your server IP address. We will use a reverse proxy like Caddy or Nginx to handle SSL certificates and route traffic securely.
If you plan on building advanced automation pipelines, you can easily connect your self-hosted tools. For example, you can self-host n8n with Docker Compose and PostgreSQL on the same server to automatically sync new leads from your webhooks directly into your Listmonk database.
Initialize the Listmonk Database
This is the step where most people get stuck. If you try to run docker compose up -d right now, the Listmonk container will crash repeatedly. This happens because the PostgreSQL database is empty, and Listmonk needs to run its initial migrations and install its schema first.
To initialize the database, run the following command in your terminal:
docker compose run --rm app ./listmonk --install
This command tells Docker to spin up the database container, start a temporary Listmonk container, run the installation script to create the tables, and then clean up the temporary container. You should see output indicating that the database tables were successfully created.
If you ever need to upgrade Listmonk in the future, you will run a similar command to apply migrations:
docker compose run --rm app ./listmonk --upgrade
Running Listmonk and Setting Up a Reverse Proxy
With the database initialized, you can now start your containers in background mode:
docker compose up -d
Verify that both containers are running properly by checking the logs:
docker compose ps
To make Listmonk accessible over the web with HTTPS, we should set up a reverse proxy. I prefer Caddy because it automatically provisions and renews SSL certificates from Let’s Encrypt without any complex configuration.
If you install Caddy on your host server, your Caddyfile configuration is as simple as this:
newsletter.yourdomain.com { reverse_proxy 127.0.0.1:9000
}
After reloading Caddy, visit your domain in your browser. You will be prompted for the username and password you defined in your config.toml file. Once logged in, you will be greeted by the clean, fast Listmonk dashboard.
Troubleshooting Common Gotchas
Even simple setups can hit snags. Here are the issues I ran into when first deploying this configuration and how to resolve them.
Database Connection Refused
If your Listmonk container keeps restarting and logs show dial tcp: lookup db: no such host or connection refused, your app container is trying to connect before the PostgreSQL container is fully ready to accept connections. Ensure your config.toml uses host = "db" and that both containers share the same Docker network.
SMTP Connection Failures
When configuring your SMTP server inside the Listmonk UI, you might get connection timeouts. This usually happens because cloud providers block outgoing port 25 by default. Always use port 587 with STARTTLS or port 465 with SSL/TLS. If you are using Amazon SES, verify that your credentials are correct and your SES account is out of the sandbox mode.
Large Import Timeouts
If you are importing a list of 100,000 subscribers and the upload fails midway, your reverse proxy might be timing out. You may need to increase the body size limit and timeout values in your Nginx or Caddy configuration to handle large CSV uploads.
Self-Host Listmonk FAQ
Can I run Listmonk on a 5 dollar VPS?
Yes. Because Listmonk is written in Go, it uses virtually no idle memory (often less than 30MB). A standard 1GB RAM VPS is more than enough to run both PostgreSQL and Listmonk, and it can easily handle sending tens of thousands of emails per hour.
How do I back up my Listmonk data?
Since all your subscriber data and campaign history live in PostgreSQL, backing up is simple. You can run a standard pg_dump command inside your database container. You can find more details on PostgreSQL backup strategies on the PostgreSQL official website.
docker exec -t listmonk_db pg_dumpall -c -U listmonk > backup.sql
How does Listmonk compare to Mailchimp?
Listmonk is a self-hosted tool focused on speed, simplicity, and list management. It does not have a drag-and-drop visual template builder like Mailchimp; instead, you write templates using HTML and Markdown. It is designed for developers and marketers who want direct control over their templates and raw sending speeds.
Next Steps for Your Self-Hosted Stack
Now that your newsletter system is up and running, you should set up analytics to see how your campaigns are performing. Instead of using privacy-invasive trackers, you can self-host Umami Analytics with Docker Compose to track link clicks and website traffic from your campaigns while keeping full control of your user data.

