If you want to self-host multiple open source projects within one compute machine, this is your project. This is a Terraform setup that's using Traefik as a reverse proxy and it is currently installing: Plausible Analytics, ListMonk (mailing lists) and Grafana, Prometheus and Traefik for monitoring.
More services can be added in the future. I will also accept pull request in order to grow this into a framework.
This deploys on ONE droplet:
- Traefik v3 (TLS, routing, dashboard, Prometheus metrics)
- Plausible (Postgres + ClickHouse)
- Listmonk (Postgres)
- Prometheus + Grafana
DNS: add A-records manually.
- Terraform ≥ 1.5
- DigitalOcean API token (export as
DIGITALOCEAN_TOKEN) - A reachable SSH key on the droplet (you’ll SSH as
rootby default)
Install:
brew install terraform doctl || trueFind your droplet ID:
doctl auth init
doctl compute droplet listConfigure
Copy example vars and edit:
cp terraform.tfvars.example terraform.tfvarsEdit:
droplet_id(existing droplet)domainandhosts(subdomains)le_email, SMTP vars
SMTP for Plausible vs Listmonk
- Plausible reads specific env keys. This stack writes the correct keys to
plausible-conf.env:MAILER_EMAIL,MAILER_NAMESMTP_HOST_ADDR,SMTP_HOST_PORT,SMTP_USER_NAME,SMTP_USER_PWDSMTP_HOST_TLS_ENABLED,SMTP_HOST_SSL_ENABLED
- Listmonk takes SMTP settings via its own
LISTMONK_smtp__*env vars from the Compose file. It does not useplausible-conf.env.
Email senders
- Plausible:
mailer_emailand optionalmailer_name(default "Plausible"). - Listmonk:
listmonk_from_emailand the global SMTP vars (smtp_*). From name defaults to "Newsletter" (change later in Listmonk UI if desired).
TLS/SSL
- Use
smtp_tls_enabledfor STARTTLS (typically true on port 587) andsmtp_ssl_enabledfor SSL-on-connect (typically true on port 465). Set only the one your SMTP relay needs.
Google Search Console (optional)
- To enable GSC imports in Plausible CE, set in
terraform.tfvars:google_client_idgoogle_client_secret
- The stack writes these to
plausible-conf.envasGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET. - After applying, restart only Plausible if needed:
docker compose up -d --no-deps plausible.
Run
terraform init
terraform plan
terraform apply -auto-approveWhen apply finishes, give the box 1–3 minutes to pull images and for Traefik to issue certs. Outputs will show your URLs.
SSH in:
ssh root@<droplet-ip>
cd /opt/analytics-stack
docker compose ps
docker compose pull && docker compose up -d
docker logs -f traefik- Create A-records for: plausible, listmonk, traefik, prom, grafana → droplet IPv4
- Keep port 80 open for Let’s Encrypt HTTP-01.
Dashboards (Traefik/Prom/Grafana) are exposed. Add Basic Auth by extending the Traefik labels in templates/docker-compose.tmpl (search for “Optional basic auth” comment).
Snapshot the droplet or back up Docker volumes:
plausible-db,plausible-ch,listmonk-db,grafana-data,prometheus-data.
This repo includes a static directory scaffold for overriding Listmonk’s system templates (e.g., the subscriber opt‑in email) via --static-dir.
- Edit
analytics-infra/static/email-templates/subscriber-optin.htmland paste the upstream default content from the Listmonk repo, then apply your branding. Replace the placeholder confirmation URL variable with the correct one from upstream. - The Compose template mounts
/opt/analytics-stack/staticinto the Listmonk container and starts Listmonk with--static-dir=/listmonk/static.
Deploy without restarting other services:
- Copy static files to the server
scp -r analytics-infra/static root@<droplet-ip>:/opt/analytics-stack/static- Update the Compose file on the server
- The template
templates/docker-compose.tmplis already updated to mount the static dir and pass--static-dir. - If you manage
/opt/analytics-stack/docker-compose.ymlmanually, ensure thelistmonkservice has:volumes: ["/opt/analytics-stack/static:/listmonk/static:ro"]command: ./listmonk --static-dir=/listmonk/static(it’s appended after the install step in our template)
- Restart only Listmonk (no other services)
ssh root@<droplet-ip>
cd /opt/analytics-stack
docker compose pull listmonk # optional
docker compose up -d --no-deps listmonk # restarts only listmonkData safety
- The Postgres data for Listmonk lives in the
listmonk-postgrescontainer with the named volumelistmonk-db; restarting thelistmonkapp service does not touch the database or other services. - The container mounts the static dir read‑only.
Verification
docker logs -f listmonkshould log that it’s using the provided static dir.- Subscribe using a fresh email; the confirmation email should reflect your template.
To avoid accidental volume changes and ensure the static dir is always mounted without editing the base compose file, use the provided override:
- Copy the override file to the server
scp analytics-infra/templates/docker-compose.override.yml \
root@<droplet-ip>:/opt/analytics-stack/docker-compose.override.yml- Copy the static folder if not already done
scp -r analytics-infra/static root@<droplet-ip>:/opt/analytics-stack/static- (Recommended) Fix the compose project name to keep volume names stable
ssh root@<droplet-ip>
cd /opt/analytics-stack
echo COMPOSE_PROJECT_NAME=analytics-stack | sudo tee .env >/dev/null- Apply only to Listmonk
ssh root@<droplet-ip>
cd /opt/analytics-stack
docker compose up -d --no-deps listmonkChecks
docker compose exec listmonk ls -la /listmonk/static/email-templatesdocker inspect $(docker compose ps -q listmonk) | grep -- --static-dirdocker volume ls | grep listmonk-dbshould showanalytics-stack_listmonk-db
If you’re provisioning a brand new Listmonk database, you can run a one-time init that installs the schema and then starts the app:
- Copy the init override alongside the regular override
scp analytics-infra/templates/docker-compose.override.init.yml \
root@<droplet-ip>:/opt/analytics-stack/docker-compose.override.init.yml- Include both overrides for the first start (only Listmonk)
ssh root@<droplet-ip>
cd /opt/analytics-stack
docker compose \
-f docker-compose.yml \
-f docker-compose.override.yml \
-f docker-compose.override.init.yml \
up -d --no-deps listmonk- After Listmonk is healthy, stop using the init override
# Either remove the file or simply stop including it in future commands
rm /opt/analytics-stack/docker-compose.override.init.yml || true
# From now on, just use the base + regular override
docker compose up -d --no-deps listmonkSafety notes
- Only use the init override on a fresh/empty DB. To check, run:
docker compose exec listmonk-postgres psql -U listmonk -d listmonk -c '\dt'and ensure there are no tables. - The DB volume is pinned as
analytics-stack_listmonk-db; do not change it unless you intend to switch databases.