Can I self-host ActiveCampaign?

YES, BUT · ONE WEEKEND— setup effort 3 of 4

YES, BUT — it's called Mautic. It takes one prompt, a 2048 MB VPS, and about 240 minutes. That is $59 a month you stop paying ActiveCampaign — $708 a year on the Plus plan.

Why people pay for ActiveCampaign

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

ActiveCampaign sells the part of email marketing that is not the email. Behind the campaign editor sits an automation canvas where a signup, a page view or a reply can start a sequence, hold a contact for three days, score them, tag them and hand them to a sales pipeline, and all of it keeps running while nobody is looking. The other half of the bill is arrival: a sending reputation built across a very large pool of senders, bounce and complaint handling that runs without you, and deliverability people you can escalate to on send day. The price is attached to how many contacts are stored rather than how much is sent, so it grows on a list nobody has mailed in a year, and every plan step up is also a jump in contact allowance. That shape is the usual reason someone with 5,000 contacts and two working automations starts looking for somewhere else to keep them.

ActiveCampaign plans and list prices
PlanList priceWhat it buys
Starter$19/moQuoted at the 1,000-contact tier, where this ladder starts. About $15 a month on the annual plan. One user, basic automation, no CRM.
Plusthe plan this page prices against$59/moAlso quoted at 1,000 contacts, and the tier most people mean by ActiveCampaign: full automation, landing pages, a sales pipeline and lead scoring. About $49 a month on the annual plan.
Pro$89/moAlso at 1,000 contacts. Adds split automations, attribution reporting and predictive sending. About $79 a month on the annual plan.
Enterprisequote onlyThe pricing page routes this tier to a quote request. Independent price trackers read on 2026-08-06 report a list figure near $145 a month billed annually at 1,000 contacts, which we record here as reported rather than as read off the vendor's page.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by Mautic

One project, named before the prompt, so you know what you are about to install.

Contacts, segments, drip campaigns, forms and site tracking on your own hostname, with the sending handed to a relay you pick.

The only open project that attempts the whole surface rather than a slice of it. Contacts with custom fields, dynamic segments, a visual campaign builder with waits and decisions, forms and landing pages, lead scoring, and a tracking script that ties a page view on your own site back to a contact record. What you take on is the part ActiveCampaign hides: every one of those moving pieces is driven by cron jobs you now operate, so a segment that never updates and a campaign step that never fires look exactly like a broken install, and the sending, the reputation and the SPF, DKIM and DMARC records belong to a relay you sign up for yourself. It is the heaviest thing in this catalogue's marketing shelf and it is the honest answer to this particular bill.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Mauticour pickONE WEEKEND— setup effort 3 of 4

    Contacts, segments, drip campaigns, forms and site tracking on your own hostname, with the sending handed to a relay you pick.

    The only open project that attempts the whole surface rather than a slice of it. Contacts with custom fields, dynamic segments, a visual campaign builder with waits and decisions, forms and landing pages, lead scoring, and a tracking script that ties a page view on your own site back to a contact record. What you take on is the part ActiveCampaign hides: every one of those moving pieces is driven by cron jobs you now operate, so a segment that never updates and a campaign step that never fires look exactly like a broken install, and the sending, the reputation and the SPF, DKIM and DMARC records belong to a relay you sign up for yourself. It is the heaviest thing in this catalogue's marketing shelf and it is the honest answer to this particular bill.

  2. listmonkONE WEEKEND— setup effort 3 of 4

    Your mailing list, your campaigns and your click stats out of one Go binary, with the sending handed to an SMTP relay you pick.

    The lighter answer, and the right one if broadcast newsletters are the whole job. One Go binary and one PostgreSQL, a deeper API, and throughput behaviour that people have already pushed to hundreds of thousands of subscribers. It ranks second because it does not pretend to be an automation platform: there is no campaign canvas, no lead scoring, no site tracking and no pipeline, so if the ActiveCampaign account is really a list plus a monthly send, this is a fraction of the operational load. If the automations are the reason for the subscription, listmonk does not replace them.

The swap

You're paying

ActiveCampaign

$59/mo · $708/yr

is replaced by

You'd run

Mautic

ONE WEEKEND · ~240 min to running · 2048 MB RAM

ActiveCampaign Plus · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~240 min3–24 hours, through the first backup

The prompt

Two paths to the same Mautic: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

349 lines · 14,997 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Mautic 7.1.3 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname and for `<ADMIN_EMAIL>` once and
stop until they answer. The A record must already point here. Say why when you ask: that
hostname becomes Mautic's Site URL, and every link, unsubscribe address and tracking pixel in
every message they send is built from it.

Mautic, its cron container and MariaDB need 2048 MB of RAM available and 10 GB free on /srv.
The image caps each PHP process at 512 MB; this install raises both to 768 MB. Both images
publish amd64 and arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a name nobody can resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mautic /srv/mautic/backups
sudo install -d -m 750 /srv/mautic/config /srv/mautic/logs /srv/mautic/media /srv/mautic/media/files /srv/mautic/media/images
sudo install -d -m 700 /srv/mautic/mariadb
ls -la /srv/mautic
```

Assert: `backups` owned by the login user, `config`, `logs` and `media` at mode `750`, and
`mariadb` at `700`. Leave those four to their containers: the image chowns its mounts to
www-data at every start, MariaDB chowns its own data directory, and a directory claimed
first is how both fail.

## 3. Secrets

Three: the `mautic` database password, the MariaDB root password, and the one for the
administrator account step 7 creates. Print none of them and keep all three out of your summary
and every log line.

```bash
umask 077
cat > /srv/mautic/.env <<EOF
MARIADB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
MAUTIC_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/mautic/.env
umask 022
ls -l /srv/mautic/.env
```

Assert: mode `-rw-------` and the login user's name twice. The admin one also reaches the web
container, because step 7's installer takes it from there rather than off a command line, where
it would sit in shell history and `ps` output.

## 4. compose.yml

```bash
cat > /srv/mautic/compose.yml <<'EOF'
# Mautic · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ....... https://github.com/mautic/docker-mautic
#   container roles .... https://github.com/mautic/docker-mautic/blob/main/common/docker-entrypoint.sh
#   cron jobs .......... https://docs.mautic.org/en/7.1/configuration/cron_jobs.html
#
# `mautic_web` is Apache with PHP; `mautic_cron` is the same image under
# DOCKER_MAUTIC_ROLE=mautic_cron, which waits for the install then runs the
# crontab it ships: segments, campaigns and triggers every 15 minutes. No
# worker container: 7.1.3 defaults both messenger transports to sync://.
# MariaDB rather than upstream's mysql:lts, both documented. Every ${...}
# comes from /srv/mautic/.env, mode 600. Digests read 2026-08-06; amd64 and
# arm64 both published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

x-mautic-env: &mautic-env
  MAUTIC_DB_HOST: db
  MAUTIC_DB_DATABASE: mautic
  MAUTIC_DB_USER: mautic
  MAUTIC_DB_PASSWORD: ${MARIADB_PASSWORD}
  PHP_INI_VALUE_MEMORY_LIMIT: 768M

x-mautic-volumes: &mautic-volumes
  - /srv/mautic/config:/var/www/html/config
  - /srv/mautic/logs:/var/www/html/var/logs
  - /srv/mautic/media/files:/var/www/html/docroot/media/files
  - /srv/mautic/media/images:/var/www/html/docroot/media/images

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: mautic-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: mautic
      MARIADB_USER: mautic
      MARIADB_PASSWORD: ${MARIADB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/mautic/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  mautic_web:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-web
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_web
      # Read by step 7's install command, by nothing in the image.
      MAUTIC_ADMIN_PASSWORD: ${MAUTIC_ADMIN_PASSWORD}
    volumes: *mautic-volumes
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 30s
      interval: 10s
      retries: 30
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8161.
      - "127.0.0.1:8161:80"
    depends_on:
      db:
        condition: service_healthy

  mautic_cron:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-cron
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_cron
    volumes: *mautic-volumes
    depends_on:
      mautic_web:
        condition: service_healthy
EOF
cd /srv/mautic && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Upstream's own example runs the cron role as its own container. Without
it, segments never refresh and campaign steps never fire.

## 5. Caddy and TLS

Append the block below, `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-mautic
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mautic · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.mautic.org/en/7.1/configuration/settings.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is Mautic's Site URL, and every link and
# tracking pixel it writes into a message is built from it.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Caddy terminates TLS and adds X-Forwarded-For; step 7's trusted_proxies
	# list tells Mautic to believe it. 8161 is the loopback port compose
	# publishes here, not a container port and not in the firewall.
	reverse_proxy 127.0.0.1:8161
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-mautic, reload and
report the objection. Caddy gets the certificate on the first request and renews it.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a Prompt Zero box:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8161 stays closed because compose binds it to loopback, 3306 because the database
gets no host port, and nothing opens for mail. Assert: `Status: active`, rules for 80, 443/tcp
and 443/udp, nothing for 8161 or 3306.

## 7. Start and verify

The first start pulls about 1.5 GB. Mautic ships with no schema and no account; the last line
creates both.

```bash
cd /srv/mautic
docker compose pull
docker compose up -d
for i in $(seq 1 40); do state=$(docker inspect -f '{{.State.Health.Status}}' mautic-web 2>/dev/null || echo none); echo "$i $state"; [ "$state" = healthy ] && break; sleep 10; done
docker compose exec -T -u www-data -w /var/www/html mautic_web sh -c 'php ./bin/console mautic:install https://<DOMAIN> --admin_email <ADMIN_EMAIL> --admin_password "$MAUTIC_ADMIN_PASSWORD" --force'
```

Assert: the loop ends on `healthy` and the install prints `Install complete`. If not, stop, run
`docker compose logs --tail 40 mautic_web` and `docker compose logs --tail 20 db`, and name the
likely step: a database that never reports healthy is step 2, and so is an entrypoint refusing a
directory it cannot chown. A running container is not success.

Now the setting the installer never asks about, which upstream calls mandatory behind a TLS
proxy, and the checks:

```bash
cd /srv/mautic
docker compose exec -T -u www-data mautic_web sh -c 'cat >> /var/www/html/config/local.php' <<'EOF'
$parameters['trusted_proxies'] = ['127.0.0.1', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
EOF
docker compose restart mautic_web
sleep 25
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/s/login
curl -sS https://<DOMAIN>/s/login | grep -c 'Username or email'
docker compose exec -T mautic_cron crontab -l -u www-data | grep -c 'mautic:segments:update'
docker compose exec -T -u www-data -w /var/www/html mautic_web php ./bin/console mautic:segments:update --no-ansi && echo "console OK"
```

Assert all four, printing what you received. The curl prints `200` and the grep `1`. The
crontab grep prints `1`, so the cron container holds the schedule that keeps segments and
campaigns moving; a `0` means it is still waiting on the install, so restart `mautic_cron` and
run the line again. The last line prints `console OK`: the console booted, read the
installer's config and reached the database. It lists no segments; a fresh install has none.

The first screen at https://<DOMAIN>/s/login shows `Username or email` in the first field, a
`Password` field, a `Keep me logged in` box and a `Login` button.

STOP: tell the user to do these three things and wait. Do not continue until they confirm.

- Read the administrator password with `sudo grep MAUTIC_ADMIN_PASSWORD /srv/mautic/.env`, put
  it in their password manager, and log in at https://<DOMAIN>/s/login as `admin`.
- Settings -> Configuration -> Email Settings: the mailer ships pointed at
  `smtp://localhost:1025`, which is nothing, and the from-address at `email@yoursite.com`.
  Replace both with their relay's and an address on a domain they own, then use Test connection
  before saving.
- Settings -> Configuration -> System Settings: list every site they mean to drop the tracking
  script on under CORS Valid Domains. Cross-origin calls are restricted by default, so a form
  on an unlisted domain fails quietly.

Once they confirm, check the mail settings moved:

```bash
cd /srv/mautic
docker compose exec -T -u www-data -w /var/www/html mautic_web php -r 'include "config/local.php"; $d=$parameters["mailer_dsn"]??""; $f=$parameters["mailer_from_email"]??""; echo ($d===""||str_contains($d,"localhost:1025")?"DSN default":"DSN moved"),"\n",($f===""||$f==="email@yoursite.com"?"FROM default":"FROM moved"),"\n";'
```

Assert: `DSN moved` and `FROM moved`. It prints verdicts, never values: the DSN now
carries the relay password. Either `default` means every campaign fails at the moment of
sending, a step-7 failure and not a step-10 mystery.

## 8. First backup and restore

Two artifacts: the database holds contacts, consent, segments, campaigns and recorded visits;
the config archive rebuilds the service around it, including the local.php with the installer's
secret key.

```bash
cd /srv/mautic
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/mautic/backups/mautic-db-$(date +%F).sql.gz
sudo tar -czf /srv/mautic/backups/mautic-config-$(date +%F).tar.gz -C /srv/mautic compose.yml .env config media -C /etc/caddy Caddyfile
ls -lh /srv/mautic/backups/
```

Assert: both exist and are non-empty; print both sizes. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database. Both archives carry live
credentials, local.php the relay's and .env the database's; tell the user that.

A backup on the same disk as the data is not a backup. Run this from the user's machine, not
the server:

```bash
mkdir -p ~/backups/mautic
scp vps:/srv/mautic/backups/* ~/backups/mautic/
```

To restore: `docker compose down`, `sudo rm -rf /srv/mautic/mariadb`, recreate it as in step 2,
untar the config archive into /srv/mautic, `docker compose up -d db`, wait 40 seconds for
healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d` and re-run step 7's asserts. The opt-in and its timestamp for every
contact live in that database, and a list restored from a backup nobody took is a list they may
no longer mail.

## 9. Updating later

New versions are at https://github.com/mautic/mautic/releases. Take both backups first, then
edit both Mautic image lines in compose.yml to the new tag and digest:

```bash
cd /srv/mautic
docker compose pull
docker compose up -d
docker compose logs --tail 40 mautic_web
```

The web container runs its own database migrations on the way up, so watch that log until
they settle, then re-run step 7's asserts. Major versions carry UPGRADE notes; read those
first.

## 10. What will probably go wrong

Nothing appears to work for fifteen minutes. I imported contacts, built a segment
that should have matched all of them, watched it sit at zero, and started re-reading the
filters. The segment was fine. Segments are recomputed by `mautic:segments:update` in the cron
container on a quarter-hour schedule, campaigns five minutes behind that and triggers five
behind those, so the Mautic on screen is always a little behind what was done to it. When a
count looks stale, run step 7's `mautic:segments:update` line by hand and check the cron
container in `docker compose ps` first.

## 11. Out of scope

- Do not add the `mautic_worker` container or set `MAUTIC_MESSENGER_DSN_EMAIL`. That moves mail
  into a queue and makes this a four-service install to watch.
- Do not enable the commented-out crontab entries for inbound email, webhooks or integration
  syncing. Each wants credentials this install does not have.
- Do not install Marketplace plugins or themes here. They write into the container's code tree,
  which is not a mounted volume and dies at the next pull.
- Do not configure a MaxMind or IP-lookup service. Mautic records visits without one.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Mautic 7.1.3 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box, and `<ADMIN_EMAIL>` with the address your one administrator account will carry.

Read this before step 1, because it is the decision here you cannot undo. `<DOMAIN>` becomes
Mautic's Site URL, and every link, unsubscribe address and tracking pixel in every message you
send is built from it. Change it later and the links in mail people have already received stop
resolving.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that
does not resolve and failed attempts count against a rate limit you cannot see. Under 2048 MB
of RAM, stop and resize the box rather than installing: this is PHP running in two containers
plus a database, and the OOM killer arrives during your first import.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mautic /srv/mautic/backups
sudo install -d -m 750 /srv/mautic/config /srv/mautic/logs /srv/mautic/media /srv/mautic/media/files /srv/mautic/media/images
sudo install -d -m 700 /srv/mautic/mariadb
ls -la /srv/mautic
```

You should see: seven directories, `backups` owned by you, `mariadb` at `drwx------` owned by
root.

If you do not: leave the last four to their containers on purpose. The Mautic image starts as
root, checks its four mounts exist and chowns them to www-data, and MariaDB chowns its own data
directory. A directory you have already chowned to yourself is the usual reason one of them
refuses to initialise.

## 3. Secrets

Three secrets, all generated here on the server, all going straight into a file only you can
read: the `mautic` database password, the MariaDB root password, and the password for the
administrator account step 7 creates.

```bash
umask 077
cat > /srv/mautic/.env <<EOF
MARIADB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
MAUTIC_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/mautic/.env
umask 022
ls -l /srv/mautic/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/mautic/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all
three, which is fine before the database exists and a problem afterwards: MariaDB keeps the
password it was created with, so a changed one on an existing volume shows up as an access
denied error in the Mautic log rather than as anything about passwords.

Do not paste that file, any of the three values, or any command output containing them into
this chat window. Read the administrator password when you need it with
`sudo grep MAUTIC_ADMIN_PASSWORD /srv/mautic/.env`, in your own terminal, and put it straight
into your password manager. Know one more place it lives: step 7 hands it to the web container
as an environment variable, so `docker inspect` on this box can read it, the same boundary the
docker group already crosses.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/mautic/compose.yml <<'EOF'
# Mautic · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ....... https://github.com/mautic/docker-mautic
#   container roles .... https://github.com/mautic/docker-mautic/blob/main/common/docker-entrypoint.sh
#   cron jobs .......... https://docs.mautic.org/en/7.1/configuration/cron_jobs.html
#
# `mautic_web` is Apache with PHP; `mautic_cron` is the same image under
# DOCKER_MAUTIC_ROLE=mautic_cron, which waits for the install then runs the
# crontab it ships: segments, campaigns and triggers every 15 minutes. No
# worker container: 7.1.3 defaults both messenger transports to sync://.
# MariaDB rather than upstream's mysql:lts, both documented. Every ${...}
# comes from /srv/mautic/.env, mode 600. Digests read 2026-08-06; amd64 and
# arm64 both published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

x-mautic-env: &mautic-env
  MAUTIC_DB_HOST: db
  MAUTIC_DB_DATABASE: mautic
  MAUTIC_DB_USER: mautic
  MAUTIC_DB_PASSWORD: ${MARIADB_PASSWORD}
  PHP_INI_VALUE_MEMORY_LIMIT: 768M

x-mautic-volumes: &mautic-volumes
  - /srv/mautic/config:/var/www/html/config
  - /srv/mautic/logs:/var/www/html/var/logs
  - /srv/mautic/media/files:/var/www/html/docroot/media/files
  - /srv/mautic/media/images:/var/www/html/docroot/media/images

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: mautic-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: mautic
      MARIADB_USER: mautic
      MARIADB_PASSWORD: ${MARIADB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/mautic/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  mautic_web:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-web
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_web
      # Read by step 7's install command, by nothing in the image.
      MAUTIC_ADMIN_PASSWORD: ${MAUTIC_ADMIN_PASSWORD}
    volumes: *mautic-volumes
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 30s
      interval: 10s
      retries: 30
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8161.
      - "127.0.0.1:8161:80"
    depends_on:
      db:
        condition: service_healthy

  mautic_cron:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-cron
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_cron
    volumes: *mautic-volumes
    depends_on:
      mautic_web:
        condition: service_healthy
EOF
cd /srv/mautic && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/mautic/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/mautic/compose.yml` and paste again in one go. The third service is not optional.
Upstream's own example runs the cron role as its own container, and without it segments never
refresh and campaign steps never fire, which is a Mautic that looks installed and does nothing.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-mautic
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mautic · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.mautic.org/en/7.1/configuration/settings.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is Mautic's Site URL, and every link and
# tracking pixel it writes into a message is built from it.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Caddy terminates TLS and adds X-Forwarded-For; step 7's trusted_proxies
	# list tells Mautic to believe it. 8161 is the loopback port compose
	# publishes here, not a container port and not in the firewall.
	reverse_proxy 127.0.0.1:8161
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-mautic /etc/caddy/Caddyfile`, reload,
and paste again. Caddy gets the certificate on the first request and renews it on its own, so
there is nothing to schedule.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8161` or `3306`.

If you do not: delete anything for those two with `sudo ufw delete allow 8161`. 8161 is bound
to 127.0.0.1 by the compose file and 3306 is never published, so the database has no host port
a firewall rule could apply to. Nothing opens for mail either, because the connection to your
relay is outbound. `Status: inactive` is a different problem: Prompt Zero left this firewall
enabled, so something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

The first start pulls about 1.5 GB and chowns four mounted directories, so this takes minutes
rather than seconds. Mautic ships with no schema and no account, and the last command creates
both.

```bash
cd /srv/mautic
docker compose pull
docker compose up -d
for i in $(seq 1 40); do state=$(docker inspect -f '{{.State.Health.Status}}' mautic-web 2>/dev/null || echo none); echo "$i $state"; [ "$state" = healthy ] && break; sleep 10; done
docker compose exec -T -u www-data -w /var/www/html mautic_web sh -c 'php ./bin/console mautic:install https://<DOMAIN> --admin_email <ADMIN_EMAIL> --admin_password "$MAUTIC_ADMIN_PASSWORD" --force'
```

You should see: the loop counting up to `healthy`, then the installer printing its steps and
finishing on `Install complete`.

If you do not: `Mautic already installed` means a previous attempt got there, and you can move
on. A loop that never leaves `starting` is usually the database, so run
`docker compose logs --tail 20 db` first and `docker compose logs --tail 40 mautic_web` second.
An entrypoint complaining it cannot chown a volume is step 2 done wrong.

Now the setting the installer never asks about, which upstream calls mandatory behind a
TLS-terminating proxy, and the four checks that decide whether this worked:

```bash
cd /srv/mautic
docker compose exec -T -u www-data mautic_web sh -c 'cat >> /var/www/html/config/local.php' <<'EOF'
$parameters['trusted_proxies'] = ['127.0.0.1', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
EOF
docker compose restart mautic_web
sleep 25
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/s/login
curl -sS https://<DOMAIN>/s/login | grep -c 'Username or email'
docker compose exec -T mautic_cron crontab -l -u www-data | grep -c 'mautic:segments:update'
docker compose exec -T -u www-data -w /var/www/html mautic_web php ./bin/console mautic:segments:update --no-ansi && echo "console OK"
```

You should see, in order: `200`, then `1`, then `1`, then `console OK`. That last command lists
no segments on a fresh install, because there are none yet; what it proves is that the command
line booted the app, read the config the installer wrote and reached the database.

If you do not: a `500` from the first curl is a typo in the line you appended, so read it back
with `docker compose exec -T mautic_web tail -3 /var/www/html/config/local.php`. A crontab grep
of `0` means the cron container is still waiting for the install to land: run
`docker compose restart mautic_cron`, wait a minute, and try that line again. Without it your
segments never update and your campaigns never fire, and nothing on the screen will tell you.

The first screen at https://<DOMAIN>/s/login shows `Username or email` in the first field, a
`Password` field, a `Keep me logged in` box and a `Login` button. Log in as `admin` with the
password from step 3. A green `docker compose ps` is not success; those four lines are.

Now three things only you can do, in the browser, under Settings -> Configuration:

- Email Settings: the mailer ships pointed at `smtp://localhost:1025`, which is nothing, and
  the from-address at `email@yoursite.com`. Replace both with your relay's host, port and
  credentials and an address on a domain you control, then use Test connection before saving.
- System Settings: list every website you mean to drop the tracking script on under CORS Valid
  Domains. Cross-origin calls are restricted by default, so a form on another domain fails
  quietly until it is listed.
- While you are there, set your own timezone and default from-name.

```bash
cd /srv/mautic
docker compose exec -T -u www-data -w /var/www/html mautic_web php -r 'include "config/local.php"; $d=$parameters["mailer_dsn"]??""; $f=$parameters["mailer_from_email"]??""; echo ($d===""||str_contains($d,"localhost:1025")?"DSN default":"DSN moved"),"\n",($f===""||$f==="email@yoursite.com"?"FROM default":"FROM moved"),"\n";'
```

You should see: `DSN moved` and `FROM moved`. The command prints those verdicts instead of the
values on purpose: the DSN now contains your relay's password, and neither this terminal's
scrollback nor this chat window is where that belongs.

If you do not: either `default` means every campaign you build will fail at the moment of
sending, with an error that talks about a connection rather than about configuration. Both
lines reading `default` means you have not saved the Configuration form yet, which is the same
problem with a different spelling.

## 8. First backup and restore

Two artifacts. The database holds contacts, consent, segments, campaigns and every recorded
visit. The config archive holds what rebuilds the service around it, including the local.php
the installer wrote the secret key into.

```bash
cd /srv/mautic
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/mautic/backups/mautic-db-$(date +%F).sql.gz
sudo tar -czf /srv/mautic/backups/mautic-config-$(date +%F).tar.gz -C /srv/mautic compose.yml .env config media -C /etc/caddy Caddyfile
ls -lh /srv/mautic/backups/
```

You should see: two files, the dump a few hundred kilobytes on a fresh install and the archive
smaller. Nothing goes offline: `--single-transaction` snapshots a running InnoDB database.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means the dump command
failed and the shell created the file anyway. Run it without `| gzip` to read the error. Both
archives carry live credentials, local.php the mail relay's and .env the database's, so keep
them where you keep a password-manager export.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/mautic
scp vps:/srv/mautic/backups/* ~/backups/mautic/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/mautic/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty install:

```bash
cd /srv/mautic
docker compose down
sudo rm -rf /srv/mautic/mariadb
sudo install -d -m 700 /srv/mautic/mariadb
docker compose up -d db
sleep 40
gunzip -c /srv/mautic/backups/mautic-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 30
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/s/login
```

You should see: no output from the restore itself, then `200` from the last command.

If you do not: `Access denied for user` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand the stakes before you
skip this. The opt-in and its timestamp for every contact you ever collect live in that
database, and a list restored from a backup nobody took is a list you may no longer mail.

## 9. Updating later

New versions are at https://github.com/mautic/mautic/releases. Take both backup artifacts
first, then edit both Mautic `image:` lines in /srv/mautic/compose.yml to the new tag and its
digest, keeping the two identical.

```bash
cd /srv/mautic
docker compose pull
docker compose up -d
docker compose logs --tail 40 mautic_web
```

You should see: migration output, then Apache starting, and no repeating restart.

If you do not: put the old tag and digest back in both lines and run the same three commands.
The web container runs its own database migrations on the way up once the config names a
database and a site URL, so a container that keeps restarting is usually a migration that did
not finish. Re-run step 7's four checks before you call the update done, and read the UPGRADE
notes in the repository before jumping a major version.

## 10. What will probably go wrong

Nothing appears to work for fifteen minutes. I imported a handful of contacts, built a segment
that should have matched all of them, watched it sit at zero, and started re-reading the
filters. The segment was fine. Segments are recomputed by `mautic:segments:update` in the cron
container on a quarter-hour schedule, campaigns five minutes behind that and triggers five
behind those, so the Mautic on screen is always a little behind what was done to it. When a
count looks stale, run step 7's `mautic:segments:update` line by hand and check the cron
container in `docker compose ps` first.

## 11. Out of scope

- Do not add the `mautic_worker` container or set `MAUTIC_MESSENGER_DSN_EMAIL`. That moves mail
  into a queue and makes this a four-service install to watch.
- Do not enable the commented-out crontab entries for inbound email, webhooks or integration
  syncing. Each wants credentials this install does not have.
- Do not install Marketplace plugins or themes here. They write into the container's code tree,
  which is not a mounted volume and dies at the next pull.
- Do not configure a MaxMind or IP-lookup service. Mautic records visits without one.

328 lines · 14,991 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Mautic 7.1.3, with its MariaDB and its cron container, under ~/selfhost/mautic,
answering at http://localhost:8161.

## 1. Preflight

Say this to the user before step 2, because it decides whether they want this install at all.
Mautic's Site URL here is http://localhost:8161, which means this computer wherever it is read,
so every link, unsubscribe address and tracking pixel Mautic writes into a campaign is dead for
whoever receives it. They get the whole machine to learn on, with their contacts and consent on
their own disk, not a sending platform.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Two PHP containers plus MariaDB need
2048 MB of RAM available and 10 GB free on the home disk; both images publish amd64 and arm64.
On macOS and Windows the figure is the host's, and Docker Desktop takes its allocation out of
it. Under either floor, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/mautic/config ~/selfhost/mautic/logs ~/selfhost/mautic/media/files ~/selfhost/mautic/media/images ~/selfhost/mautic/backups
ls -la ~/selfhost/mautic
```

Assert: five directories owned by the user. No ownership fix is needed anywhere: the image
starts as root and chowns those four mounts to www-data itself. The database gets no directory
here: step 5 keeps it in a Docker volume.

## 4. Secrets

Three: the `mautic` database password, the MariaDB root password, and the one for the
administrator account step 7 creates. Print none of them and keep all three out of your summary
and every log line.

```bash
umask 077
cat > ~/selfhost/mautic/.env <<EOF
MARIADB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
MAUTIC_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 ~/selfhost/mautic/.env
umask 022
ls -l ~/selfhost/mautic/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these run the same everywhere. On Windows
the mode bits are advisory, because NTFS does not enforce them; the real boundary is the user's
Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/mautic/compose.yml <<'EOF'
# Mautic · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image README ....... https://github.com/mautic/docker-mautic
#   container roles .... https://github.com/mautic/docker-mautic/blob/main/common/docker-entrypoint.sh
#   cron jobs .......... https://docs.mautic.org/en/7.1/configuration/cron_jobs.html
#
# Three services, every path relative to ~/selfhost/mautic/ so one file works
# on macOS, Linux and Windows. The database is a named volume, not a bind
# mount, because MariaDB chowns its data directory to its own uid and a
# home-directory bind cannot allow that on Windows. Digests read 2026-08-06;
# amd64 and arm64 both published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

x-mautic-env: &mautic-env
  MAUTIC_DB_HOST: db
  MAUTIC_DB_DATABASE: mautic
  MAUTIC_DB_USER: mautic
  MAUTIC_DB_PASSWORD: ${MARIADB_PASSWORD}
  PHP_INI_VALUE_MEMORY_LIMIT: 768M

x-mautic-volumes: &mautic-volumes
  - ./config:/var/www/html/config
  - ./logs:/var/www/html/var/logs
  - ./media/files:/var/www/html/docroot/media/files
  - ./media/images:/var/www/html/docroot/media/images

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: mautic-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: mautic
      MARIADB_USER: mautic
      MARIADB_PASSWORD: ${MARIADB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - mautic-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  mautic_web:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-web
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_web
      MAUTIC_ADMIN_PASSWORD: ${MAUTIC_ADMIN_PASSWORD}
    volumes: *mautic-volumes
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 30s
      interval: 10s
      retries: 30
    ports:
      # Loopback only: no other device on the wifi can reach 8161.
      - "127.0.0.1:8161:80"
    depends_on:
      db:
        condition: service_healthy

  mautic_cron:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-cron
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_cron
    volumes: *mautic-volumes
    depends_on:
      mautic_web:
        condition: service_healthy

volumes:
  mautic-dbdata:
EOF
cd ~/selfhost/mautic && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Three services, one published port, one named volume. Without
`mautic_cron`, segments never refresh and campaign steps never fire.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. A certificate attests a public name and
nothing here has one; browsers treat http://localhost as a secure context anyway, so pages
needing crypto work. Nothing is published beyond loopback: 8161 binds to 127.0.0.1, this
computer only, not the user's phone, not a laptop on the same wifi, not anyone on the internet.
Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/mautic/compose.yml
```

Assert: `1`, the published port `- "127.0.0.1:8161:80"`. MariaDB publishes none, so 3306 cannot
appear.

## 7. Start and verify

The first start pulls about 1.5 GB. Mautic ships with no schema and no account; the install
line creates both, with `admin@example.com` as the address because nothing here can send to a
real one.

```bash
cd ~/selfhost/mautic
docker compose pull
docker compose up -d
for i in $(seq 1 40); do state=$(docker inspect -f '{{.State.Health.Status}}' mautic-web 2>/dev/null || echo none); echo "$i $state"; [ "$state" = healthy ] && break; sleep 10; done
docker compose exec -T -u www-data -w /var/www/html mautic_web sh -c 'php ./bin/console mautic:install http://localhost:8161 --admin_email admin@example.com --admin_password "$MAUTIC_ADMIN_PASSWORD" --force'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8161/s/login
curl -sS http://localhost:8161/s/login | grep -c 'Username or email'
docker compose exec -T mautic_cron crontab -l -u www-data | grep -c 'mautic:segments:update'
docker compose exec -T -u www-data -w /var/www/html mautic_web php ./bin/console mautic:segments:update --no-ansi && echo "console OK"
```

Assert, printing what you received for each: the loop ends on `healthy`, the install prints
`Install complete`, the curl prints `200`, the page grep prints `1`, the crontab grep prints
`1`, and the last line prints `console OK`, the command line reaching the database the
installer wrote to. If any miss, stop, run `docker compose logs --tail 40 mautic_web` and
`docker compose logs --tail 20 db`, and name the likely cause: a database that never reports
healthy is step 4, where an empty password leaves MariaDB refusing to start, and a crontab `0`
is a cron container still waiting on the install, so restart `mautic_cron`. On `port is already
allocated`, find what holds 8161 with `lsof -nP -iTCP:8161 -sTCP:LISTEN`, or
`netstat -ano | findstr :8161` on Windows, and stop until the user frees it: that port is in the
Site URL and in every link. A running container is not success.

The first screen at http://localhost:8161/s/login shows `Username or email` in the first field,
a `Password` field, a `Keep me logged in` box and a `Login` button.

STOP: tell the user to read the administrator password with
`grep MAUTIC_ADMIN_PASSWORD ~/selfhost/mautic/.env`, put it in their password manager, log in at
http://localhost:8161/s/login as `admin`, and wait. Do not continue until they confirm. Tell
them what stays broken meanwhile: the mailer ships pointed at `smtp://localhost:1025` and the
from-address at `email@yoursite.com`, so nothing leaves here until Settings -> Configuration ->
Email Settings names a relay and an address they own.

## 8. First backup and restore

Two artifacts: the database holds contacts, consent, segments and campaigns; the config archive
rebuilds the service around it.

```bash
cd ~/selfhost/mautic
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/mautic/backups/mautic-db-$(date +%F).sql.gz
tar -C ~/selfhost/mautic -czf ~/selfhost/mautic/backups/mautic-config-$(date +%F).tar.gz compose.yml .env config media
ls -lh ~/selfhost/mautic/backups/
```

Assert: both exist, both are non-empty, both sizes printed. Both carry live credentials, .env
the database's and local.php the relay's once it is set; tell the user that.

They also sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a folder their sync service
watches or a USB stick, and copy both there with `cp`; in Git Bash a Windows drive is written
`/d/Backups`. Assert: the user confirms both filenames are there. If they have neither, say
plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/mautic`, untar the config archive there first so
compose.yml and .env are back before any container starts, because MariaDB reads its password
from .env when it initialises an empty volume. Then `docker compose down -v`, the one
place `-v` belongs because it drops the old volume on purpose, `docker compose up -d db`, wait
40 seconds for healthy, then pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d` and re-run step 7's asserts.

## 9. Updating later

New versions are at https://github.com/mautic/mautic/releases. Back up first, then edit both
Mautic image lines in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/mautic
docker compose pull
docker compose up -d
docker compose logs --tail 40 mautic_web
```

The web container migrates its own database on the way up, so watch that log until it settles,
then re-run step 7's asserts.

## 10. What will probably go wrong

I scheduled a campaign for six in the morning, shut the laptop, and found nothing had happened.
Nothing was broken. A campaign step fires when `mautic:campaigns:trigger` runs in the cron
container, that container runs only while Docker Desktop runs, and Docker Desktop runs only
while the machine is awake. Mautic here does as much work as the computer is switched on for,
and after a reboot `restart: unless-stopped` waits for the daemon, so run
`cd ~/selfhost/mautic && docker compose up -d` first.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change the Site URL to this machine's LAN address so a phone can reach it, and do not
  rebind 8161 to 0.0.0.0. That puts a marketing database with a login form on every network the
  user joins.
compose.local.ymlthe services, pinned · local layout82 lines

authored from upstream docs, never pasted · 2,917 bytes

# Mautic · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image README ....... https://github.com/mautic/docker-mautic
#   container roles .... https://github.com/mautic/docker-mautic/blob/main/common/docker-entrypoint.sh
#   cron jobs .......... https://docs.mautic.org/en/7.1/configuration/cron_jobs.html
#
# Three services, every path relative to ~/selfhost/mautic/ so one file works
# on macOS, Linux and Windows. The database is a named volume, not a bind
# mount, because MariaDB chowns its data directory to its own uid and a
# home-directory bind cannot allow that on Windows. Digests read 2026-08-06;
# amd64 and arm64 both published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

x-mautic-env: &mautic-env
  MAUTIC_DB_HOST: db
  MAUTIC_DB_DATABASE: mautic
  MAUTIC_DB_USER: mautic
  MAUTIC_DB_PASSWORD: ${MARIADB_PASSWORD}
  PHP_INI_VALUE_MEMORY_LIMIT: 768M

x-mautic-volumes: &mautic-volumes
  - ./config:/var/www/html/config
  - ./logs:/var/www/html/var/logs
  - ./media/files:/var/www/html/docroot/media/files
  - ./media/images:/var/www/html/docroot/media/images

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: mautic-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: mautic
      MARIADB_USER: mautic
      MARIADB_PASSWORD: ${MARIADB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - mautic-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  mautic_web:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-web
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_web
      MAUTIC_ADMIN_PASSWORD: ${MAUTIC_ADMIN_PASSWORD}
    volumes: *mautic-volumes
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 30s
      interval: 10s
      retries: 30
    ports:
      # Loopback only: no other device on the wifi can reach 8161.
      - "127.0.0.1:8161:80"
    depends_on:
      db:
        condition: service_healthy

  mautic_cron:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-cron
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_cron
    volumes: *mautic-volumes
    depends_on:
      mautic_web:
        condition: service_healthy

volumes:
  mautic-dbdata:

agent-readable mirror: /self-host/activecampaign.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned82 lines

authored from upstream docs, never pasted · 3,129 bytes

# Mautic · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ....... https://github.com/mautic/docker-mautic
#   container roles .... https://github.com/mautic/docker-mautic/blob/main/common/docker-entrypoint.sh
#   cron jobs .......... https://docs.mautic.org/en/7.1/configuration/cron_jobs.html
#
# `mautic_web` is Apache with PHP; `mautic_cron` is the same image under
# DOCKER_MAUTIC_ROLE=mautic_cron, which waits for the install then runs the
# crontab it ships: segments, campaigns and triggers every 15 minutes. No
# worker container: 7.1.3 defaults both messenger transports to sync://.
# MariaDB rather than upstream's mysql:lts, both documented. Every ${...}
# comes from /srv/mautic/.env, mode 600. Digests read 2026-08-06; amd64 and
# arm64 both published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

x-mautic-env: &mautic-env
  MAUTIC_DB_HOST: db
  MAUTIC_DB_DATABASE: mautic
  MAUTIC_DB_USER: mautic
  MAUTIC_DB_PASSWORD: ${MARIADB_PASSWORD}
  PHP_INI_VALUE_MEMORY_LIMIT: 768M

x-mautic-volumes: &mautic-volumes
  - /srv/mautic/config:/var/www/html/config
  - /srv/mautic/logs:/var/www/html/var/logs
  - /srv/mautic/media/files:/var/www/html/docroot/media/files
  - /srv/mautic/media/images:/var/www/html/docroot/media/images

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: mautic-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: mautic
      MARIADB_USER: mautic
      MARIADB_PASSWORD: ${MARIADB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/mautic/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  mautic_web:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-web
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_web
      # Read by step 7's install command, by nothing in the image.
      MAUTIC_ADMIN_PASSWORD: ${MAUTIC_ADMIN_PASSWORD}
    volumes: *mautic-volumes
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 30s
      interval: 10s
      retries: 30
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8161.
      - "127.0.0.1:8161:80"
    depends_on:
      db:
        condition: service_healthy

  mautic_cron:
    image: mautic/mautic:7.1.3-apache@sha256:373a3de08dfce296e31fe0b7caf269594c43020454628f445c169990b9af4d5e
    container_name: mautic-cron
    restart: unless-stopped
    environment:
      <<: *mautic-env
      DOCKER_MAUTIC_ROLE: mautic_cron
    volumes: *mautic-volumes
    depends_on:
      mautic_web:
        condition: service_healthy
Caddyfilethe hostname and TLS23 lines

authored from upstream docs, never pasted · 862 bytes

# Mautic · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.mautic.org/en/7.1/configuration/settings.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is Mautic's Site URL, and every link and
# tracking pixel it writes into a message is built from it.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Caddy terminates TLS and adds X-Forwarded-For; step 7's trusted_proxies
	# list tells Mautic to believe it. 8161 is the loopback port compose
	# publishes here, not a container port and not in the firewall.
	reverse_proxy 127.0.0.1:8161
}
install.shthe same install, no agent178 lines

authored from upstream docs, never pasted · 8,659 bytes

#!/usr/bin/env bash
# Mautic · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=mautic.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/mautic/docker-mautic
#   https://github.com/mautic/docker-mautic/blob/main/common/docker-entrypoint.sh
#   https://docs.mautic.org/en/7.1/configuration/cron_jobs.html
#   https://docs.mautic.org/en/7.1/configuration/settings.html
#
# Three secrets are generated here, on this machine: the mautic database
# password, the MariaDB root password, and the password for the one
# administrator account. All three go into /srv/mautic/.env with mode 600 and
# none of them is ever printed.
#
# DOMAIN_HOST becomes Mautic's Site URL. Every link, unsubscribe address and
# tracking pixel in every message this install sends is built from it, so
# changing it later breaks the mail people have already received.
#
# This script does not configure a mail relay. Mautic ships pointed at
# smtp://localhost:1025, which is nothing, and until you replace that under
# Settings -> Configuration -> Email Settings, no campaign leaves the box.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/mautic}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. mautic.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address the administrator account should carry"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; two PHP containers plus MariaDB want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# The Mautic image starts as root, checks its four mounts exist and chowns them
# to www-data, and MariaDB chowns its own data directory. Neither is chowned
# here, because a directory already claimed is how both fail on first boot.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/config" "$APP_DIR/logs" "$APP_DIR/media" "$APP_DIR/media/files" "$APP_DIR/media/images"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex for the two database credentials, base64 for the one a human types into a
# browser. Read them later with
#   sudo grep -E 'MARIADB_PASSWORD|MAUTIC_ADMIN_PASSWORD' /srv/mautic/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		MARIADB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
		MAUTIC_ADMIN_PASSWORD=$(openssl rand -base64 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-mautic"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8161 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8161 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it, then install the schema and the one account ----------------

docker compose pull
docker compose up -d

echo "==> waiting for the mautic-web container to report healthy"
for _ in $(seq 1 40); do
	state="$(docker inspect -f '{{.State.Health.Status}}' mautic-web 2>/dev/null || echo none)"
	[ "$state" = "healthy" ] && break
	sleep 10
done
[ "${state:-}" = "healthy" ] || die "mautic-web is ${state:-missing}. Check: docker compose logs --tail 40 mautic_web"

docker compose exec -T -u www-data -w /var/www/html mautic_web \
	sh -c "php ./bin/console mautic:install https://${DOMAIN_HOST} --admin_email ${ADMIN_EMAIL} --admin_password \"\$MAUTIC_ADMIN_PASSWORD\" --force"

# Upstream calls trusted proxies mandatory behind a TLS-terminating proxy, and
# the installer never asks. Without it every tracked visit is recorded as
# arriving from the Docker bridge instead of from the visitor.
if ! docker compose exec -T mautic_web grep -q trusted_proxies /var/www/html/config/local.php; then
	docker compose exec -T -u www-data mautic_web sh -c 'cat >> /var/www/html/config/local.php' <<-'PROXIES'
		$parameters['trusted_proxies'] = ['127.0.0.1', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
	PROXIES
fi
docker compose restart mautic_web
sleep 25

code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/s/login" || true)"
[ "$code" = "200" ] || die "https://${DOMAIN_HOST}/s/login answered ${code}. Check: docker compose logs --tail 40 mautic_web"

curl -sS "https://${DOMAIN_HOST}/s/login" | grep -q 'Username or email' \
	|| die "the login page did not contain 'Username or email'. Stop and investigate."

# The cron container is what keeps segments, campaigns and triggers moving.
docker compose exec -T mautic_cron crontab -l -u www-data | grep -q 'mautic:segments:update' \
	|| die "mautic-cron has no crontab yet. Run: docker compose restart mautic_cron"

# End to end: a console command reaching the database the web container wrote.
docker compose exec -T -u www-data -w /var/www/html mautic_web php ./bin/console mautic:segments:update --no-ansi >/dev/null \
	|| die "mautic:segments:update failed. Check: docker compose logs --tail 40 mautic_web"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' \
	| gzip > "$APP_DIR/backups/mautic-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/mautic-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env config media -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/mautic-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Mautic is answering at https://${DOMAIN_HOST}/s/login

	  1. Log in as admin. The password is in $APP_DIR/.env, mode 600. Read it
	     with
	       sudo grep MAUTIC_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  2. Nothing can send yet. Settings -> Configuration -> Email Settings
	     ships pointed at smtp://localhost:1025 with a from-address of
	     email@yoursite.com. Replace both with your relay and an address on a
	     domain you own, and use Test connection before saving.
	  3. Settings -> Configuration -> System Settings: list every website you
	     will drop the tracking script on under CORS Valid Domains, or its
	     forms will fail quietly.
	  4. Segments, campaigns and triggers are recomputed by the mautic-cron
	     container every 15 minutes, not when you look at a page. A count that
	     has not moved yet is usually that, not a broken filter.
	  5. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. Both carry live credentials and both sit on the same disk as
	     the data, which is not a backup. Copy them off the box tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel ActiveCampaign.

  • Cron is the product. Segments, campaign steps and scheduled sends are computed by a container running a crontab every 15 minutes, not when you open a page, so a segment stuck at zero and a campaign that never advances look exactly like a broken install and nobody emails you when that container stops. Every hour Mautic feels slow is an hour it was doing the thing it is for.
  • Deliverability becomes your job, and it is the job. Mautic composes, schedules and hands messages to an SMTP relay you sign up for; whether they arrive depends on that relay, on SPF, DKIM and DMARC records you write yourself, and on a sending reputation that starts at nothing. That is the half of the bill ActiveCampaign is really attached to, and moving the software does not move the work.
  • You own a MariaDB, and the row that matters most in it is consent. Every contact's opt-in and its timestamp live there alongside the page views, email opens and clicks the tracking script records, so a list restored from a backup you never took is a list you are no longer allowed to mail, and the dump itself is a credential once a relay is configured.
  • It is PHP, and it eats. Two PHP containers with a 768 MB memory limit each plus a database is a 2 GB box on day one, before the contact table grows, and the tracking script needs the domain of every site you paste it into listed under CORS Valid Domains or its forms fail without an error anybody sees.
  • No managed deliverability, no support on send day, no built-in SMS or WhatsApp, and no sales team using the same contact record. ActiveCampaign's upper tiers sell attribution reporting, predictive sending and a CRM alongside the automations, and none of that is here.

Where this came from

“Mautic requires a few cron jobs to handle some maintenance tasks such as updating Contacts or Campaigns, executing Campaign Actions, sending Emails, and more.”

  • Upstream's own Docker example runs the cron role as a separate container beside the web container and the database, and the crontab the image ships updates segments, campaigns and campaign triggers every 15 minutes. source
  • Mautic states that it requires cron jobs to update contacts and campaigns, execute campaign actions and send email, and recommends running segment, campaign and trigger updates on a staggered quarter-hour schedule. source
  • The official image requires MAUTIC_DB_HOST, MAUTIC_DB_USER, MAUTIC_DB_PASSWORD and DOCKER_MAUTIC_ROLE, chowns its four mounted volumes to www-data at every start, and dispatches to the web, cron or worker entrypoint from the role. source
  • A fresh install ships a mailer DSN of smtp://localhost:1025 and a from-address of email@yoursite.com, so both have to be replaced before a single campaign can leave. source
  • Upstream calls the trusted proxies setting mandatory behind a TLS-terminating proxy, and restricts cross-origin calls to the domains listed under CORS Valid Domains by default. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host ActiveCampaign?

    Not ActiveCampaign itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Mautic. Contacts, segments, drip campaigns, forms and site tracking on your own hostname, with the sending handed to a relay you pick. The install is one weekend: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces ActiveCampaign?

    Mautic. Contacts, segments, drip campaigns, forms and site tracking on your own hostname, with the sending handed to a relay you pick. The only open project that attempts the whole surface rather than a slice of it. Contacts with custom fields, dynamic segments, a visual campaign builder with waits and decisions, forms and landing pages, lead scoring, and a tracking script that ties a page view on your own site back to a contact record. What you take on is the part ActiveCampaign hides: every one of those moving pieces is driven by cron jobs you now operate, so a segment that never updates and a campaign step that never fires look exactly like a broken install, and the sending, the reputation and the SPF, DKIM and DMARC records belong to a relay you sign up for yourself. It is the heaviest thing in this catalogue's marketing shelf and it is the honest answer to this particular bill. Also evaluated and ranked below it: listmonk. The prompt on this page installs Mautic only. Mautic is GPL-3.0-or-later-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to ActiveCampaign?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Mautic itself is free and GPL-3.0-or-later-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: ActiveCampaign Plus, $59/mo — $708 a year.

  • How hard is it really?

    ONE WEEKEND — 3–24 hours. The rule that produced that verdict: a database plus one outside integration. A database and an outside service fail in completely different ways, and you have to learn both failure modes before you trust the thing with real data. Budget the second day for whichever one surprises you. The tier is derived from seven countable facts about the Mautic install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Mautic on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Mautic on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every link, unsubscribe address and tracking pixel Mautic writes into a campaign here begins with http://localhost:8161, which means the reader's own computer, so anyone you mail from this install gets a message whose links go nowhere, and the campaign schedule only advances while the machine is awake. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.