Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host Mautic, the open-source marketing-automation platform, on a VPS with the official Docker image pinned to a release, MySQL, the cron and worker containers it needs, Caddy HTTPS and tested backups.
Mautic is an open-source marketing-automation platform: contact and company records, segments, landing pages and forms, email campaigns and visual campaign workflows, with lead scoring and tracking. It is written in PHP on Symfony, stores everything in MySQL, and is licensed GPL-3.0-or-later. It is the self-hosted answer to the paid tiers of Mailchimp and HubSpot, where the bill grows with your contact count and the automation features sit behind the expensive plans.
Mautic is not a single container. A working install has a web process, a cron
runner for the scheduled jobs (segment rebuilds, campaign triggers) and a
background worker that sends queued email and records tracking hits. This guide
uses the official docker-mautic image, pinned to a release, and the "basic"
layout from its repository with three changes: secrets generated for you,
everything bound to 127.0.0.1, and Caddy in front for HTTPS. If you only want
to send a plain newsletter rather than run automation, a lighter tool such as
listmonk is easier to run; our Keila vs listmonk
comparison covers that class of tool.
Our listing for Mautic puts the floor at 2 GB of RAM, and the stack earns it. When we installed this exact layout on a 2 vCPU, 8 GB test box and let it settle, the whole stack used about 456 MB at idle. Nearly all of that is MySQL, which reserves memory for its buffer pool up front, with the Apache and PHP web container well below it. Idle is not the number to size against. An import of a large contact list, a segment rebuild over many contacts, or a campaign send makes PHP use far more, and the worker processes run alongside all of it. On 2 GB you can run a small list; if you expect tens of thousands of contacts, take 4 GB.
Disk is small at first (a few GB, mostly the images) and grows with contacts, tracking hits and uploaded media. Pick a plan whose disk you can expand.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils
If you are logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the reverse proxy needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
On a Docker host, a published container port goes around ufw. That is why the
Compose file below publishes Mautic's web port on 127.0.0.1 only: Caddy
becomes the one public door.
Paid link — we earn a commission if you shop through it.
If Docker is not on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo docker compose version
Create a directory and a .env file. Set your hostname first (this guide uses
mautic.example.com; replace it with yours). The three passwords are generated
here and never typed:
mkdir -p ~/mautic && cd ~/mautic
DOMAIN=mautic.example.com
cat > .env <<EOF
DOMAIN=$DOMAIN
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 16)
MYSQL_PASSWORD=$(openssl rand -hex 16)
MAUTIC_ADMIN_PASSWORD=$(openssl rand -hex 12)
EOF
chmod 600 .env
grep -c '=.\{12,\}' .env
The last line should print 4. Keep this file safe and include it in your
backups; it holds the database passwords and the admin password you will log in
with.
Now the Compose file. The three Mautic services use the same image, pinned to a
release, and differ only in DOCKER_MAUTIC_ROLE. The YAML anchors at the top
share one set of environment variables and volumes between them:
cd ~/mautic
cat > docker-compose.yml <<'EOF'
x-mautic-env: &mautic-env
MAUTIC_DB_HOST: db
MAUTIC_DB_PORT: "3306"
MAUTIC_DB_DATABASE: mautic_db
MAUTIC_DB_USER: mautic_db_user
MAUTIC_DB_PASSWORD: ${MYSQL_PASSWORD}
MAUTIC_MESSENGER_DSN_EMAIL: doctrine://default
MAUTIC_MESSENGER_DSN_HIT: doctrine://default
x-mautic-volumes: &mautic-volumes
- mautic-config:/var/www/html/config
- mautic-logs:/var/www/html/var/logs
- mautic-media:/var/www/html/docroot/media
services:
db:
image: mysql:8.4
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: mautic_db
MYSQL_USER: mautic_db_user
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: mysqladmin --user=$$MYSQL_USER --password=$$MYSQL_PASSWORD ping
start_period: 5s
interval: 5s
timeout: 5s
retries: 10
mautic_web:
image: mautic/mautic:7.2.1-apache
restart: always
ports:
- "127.0.0.1:8080:80"
volumes: *mautic-volumes
environment: *mautic-env
healthcheck:
test: curl -f http://localhost
start_period: 5s
interval: 5s
timeout: 5s
retries: 100
depends_on:
db:
condition: service_healthy
mautic_cron:
image: mautic/mautic:7.2.1-apache
restart: always
volumes: *mautic-volumes
environment:
<<: *mautic-env
DOCKER_MAUTIC_ROLE: mautic_cron
depends_on:
mautic_web:
condition: service_healthy
mautic_worker:
image: mautic/mautic:7.2.1-apache
restart: always
volumes: *mautic-volumes
environment:
<<: *mautic-env
DOCKER_MAUTIC_ROLE: mautic_worker
depends_on:
mautic_web:
condition: service_healthy
caddy:
image: caddy:2
restart: always
ports:
- "80:80"
- "443:443"
environment:
DOMAIN: ${DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
- mautic_web
volumes:
mysql-data:
mautic-config:
mautic-logs:
mautic-media:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
encode zstd gzip
reverse_proxy mautic_web:80
}
EOF
sudo docker compose config --quiet && echo "compose config valid"
The upstream example hardcodes change-me passwords in the file and publishes
the web port on all interfaces. Reading the passwords from .env and binding to
loopback are the two deliberate differences. Start the stack and wait for the web
container to report healthy; the bounded loop fails instead of hanging if
something is wrong:
cd ~/mautic
sudo docker compose up -d
for i in $(seq 1 60); do
sudo docker compose ps mautic_web | grep -q '(healthy)' && break
sleep 5
done
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
You will see mautic_cron and mautic_worker running but idle. Their logs say
Waiting for Mautic to be installed, which is expected: both wait until the
installer has written the site configuration, which is the next step.
Create an A record for your hostname (say mautic.example.com) pointing at
the server's public IP, and confirm it resolves before you finish the install,
because the installer records the site URL:
dig +short mautic.example.com
Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works).
Mautic normally asks you to finish a three-step wizard in the browser. The same
installer is available as a console command, which suits a server you are
configuring over SSH and lets the password come from .env rather than a form.
Give it the public https address: Mautic stores it as site_url and uses it
in every tracking link and email it builds.
cd ~/mautic
. ./.env
sudo docker compose exec -T --user www-data --workdir /var/www/html mautic_web \
php ./bin/console mautic:install "https://$DOMAIN" \
--admin_email=admin@example.com --admin_username=admin \
--admin_password="$MAUTIC_ADMIN_PASSWORD" --force
Replace admin@example.com with your own address; it is the login and where
password resets go. The command creates the schema, loads the fixtures and
creates the administrator in about ten seconds. Now check HTTPS, retrying while
the certificate is issued:
cd ~/mautic
. ./.env
code=000
for i in $(seq 1 24); do
code=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/s/login") && [ "${code%% *}" = "200" ] && break
sleep 5
done
echo "login page, status and certificate check: $code"
[ "${code%% *}" = "200" ] || false
A second number of 0 means the certificate verified. Then prove the
administrator can actually sign in, the same way the browser does: fetch the
login form, send its CSRF token with the credentials, and look for the logout
link on the dashboard:
cd ~/mautic
. ./.env
J=$(mktemp)
T=$(curl -s -c "$J" "https://$DOMAIN/s/login" | grep -o 'name="_csrf_token"[^>]*value="[^"]*"\|value="[^"]*"[^>]*name="_csrf_token"' | grep -o 'value="[^"]*"' | cut -d'"' -f2)
curl -s -b "$J" -c "$J" -o /dev/null --data-urlencode "_csrf_token=$T" -d "_username=admin" --data-urlencode "_password=$MAUTIC_ADMIN_PASSWORD" "https://$DOMAIN/s/login_check"
curl -sL -b "$J" "https://$DOMAIN/s/dashboard" | grep -c 'logout'
rm -f "$J"
Any number above 0 means you are logged in. Open https://mautic.example.com
in a browser and sign in as admin with the password from .env:
grep '^MAUTIC_ADMIN_PASSWORD=' ~/mautic/.env
Mautic does its real work in the background, and if these two containers stop, the web interface keeps loading while nothing happens.
mautic_cron runs the scheduled commands as www-data: rebuilding
segments and updating and triggering campaigns, each every 15 minutes. Its
default crontab comes from a template in the image, and that template ships
with several jobs commented out, including webhook processing, contact import
and fetching monitored email. Enable what you use by editing the crontab file
inside the container and restarting it.mautic_worker consumes the Messenger queues configured by the two
MAUTIC_MESSENGER_DSN_* variables in the Compose file. With the doctrine://
transport those queues are tables in MySQL, so there is no Redis or RabbitMQ
to run. This is what actually sends your queued emails and processes tracking
hits.Mautic does not send mail itself; it hands messages to an email transport. Under Settings, Configuration, Email Settings choose a transport (SMTP or one of the provider integrations) and send a test message from that page. Until you do, campaigns queue without delivering. A cheap VPS is also not a good place to send bulk mail from directly: port 25 is often blocked, and a new IP has no sending reputation, so use a relay.
State lives in three places: the MySQL database, the config directory (which
holds local.php, with the generated secret_key and the database settings),
and uploaded media. Your .env holds the passwords. These archives hold your
contacts, so they are created readable by their owner only. Start with the
database:
cd ~/mautic
( umask 077; sudo docker compose exec -T db sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines mautic_db' | gzip > ~/mautic-db-$(date +%F).sql.gz )
ls -lh ~/mautic-db-*.sql.gz
Then the config directory, the media directory and the stack's own files:
cd ~/mautic
( umask 077
sudo docker compose exec -T mautic_web tar czf - -C /var/www/html/config . > ~/mautic-config-$(date +%F).tar.gz
sudo docker compose exec -T mautic_web tar czf - -C /var/www/html/docroot/media . > ~/mautic-media-$(date +%F).tar.gz
tar czf ~/mautic-stack-$(date +%F).tar.gz docker-compose.yml Caddyfile .env )
ls -lh ~/mautic-config-*.tar.gz ~/mautic-media-*.tar.gz ~/mautic-stack-*.tar.gz
Copy these files off the server and run the set from cron. A backup you have never restored is a guess, so prove the dump loads. This starts a throwaway MySQL container, restores the dump into it and compares the table count with the live database:
cd ~/mautic
sudo docker rm -f mautic-restore-test >/dev/null 2>&1
sudo docker run -d --name mautic-restore-test -e MYSQL_ROOT_PASSWORD=restore-test -e MYSQL_DATABASE=mautic_db mysql:8.4 >/dev/null
for i in $(seq 1 60); do
sudo docker exec mautic-restore-test mysqladmin ping -uroot -prestore-test --silent >/dev/null 2>&1 && break
sleep 3
done
sleep 5
gunzip -c ~/mautic-db-$(date +%F).sql.gz | sudo docker exec -i mautic-restore-test mysql -uroot -prestore-test mautic_db
Q="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='mautic_db'"
LIVE=$(sudo docker compose exec -T db sh -c "mysql -N -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -e \"$Q\"")
RESTORED=$(sudo docker exec mautic-restore-test mysql -N -uroot -prestore-test -e "$Q")
echo "tables live: $LIVE, restored: $RESTORED"
sudo docker rm -f mautic-restore-test >/dev/null
[ "$LIVE" = "$RESTORED" ] && [ "$LIVE" -gt 50 ]
The final line fails the step if the counts differ or the dump is nearly empty.
The image tag is the version. To upgrade, back up first, then change the tag on all three Mautic services (they must match) to the release you want:
cd ~/mautic
sed -i 's|mautic/mautic:7.2.1-apache|mautic/mautic:NEW_VERSION-apache|' docker-compose.yml
Then pull and recreate:
cd ~/mautic
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
On every start, the web container checks whether Mautic is installed and, if it is, runs the database migrations for you, so a new image upgrades the schema automatically. That is also why the backup comes first: the migrations change your data in place. Read the release notes on the Mautic GitHub releases page before moving to a new major version, and do not skip one.
The cron and worker containers log Waiting for Mautic to be installed
forever. They wait for config/local.php to contain the database driver and
site URL. If you have not run the installer yet, run it. If you did, check that
all three Mautic services mount the same mautic-config volume.
The site works but nothing sends and campaigns do not advance. Check
sudo docker compose ps for a stopped mautic_cron or mautic_worker, then
read their logs with sudo docker compose logs mautic_worker. Also confirm an
email transport is configured under Settings, Configuration.
Links in emails and tracking pixels point at the wrong address. The
site_url the installer saved is wrong, usually because it was run with the
IP or http. Set the correct URL under Settings, Configuration, System
Settings and clear the cache with
sudo docker compose exec --user www-data --workdir /var/www/html mautic_web php ./bin/console cache:clear.
Caddy never gets a certificate. Confirm dig +short mautic.example.com
returns the server's IP and that your provider's network firewall (not just
ufw) allows inbound 80 and 443.
You are done when you can: load https://mautic.example.com with a valid
certificate, sign in as the administrator, see mautic_cron and mautic_worker
both running, send a test email from the email settings page, and restore a
database dump into a throwaway container, as above.
From here, create a first segment and a contact form, embed it on your site, and build a campaign from them. For ranked server options, see Best VPS for self-hosting. We confirmed the install, the HTTPS certificate, the administrator login, the cron and worker startup and the backup restore in our own run. We did not send email or build a campaign, so test delivery before you rely on it.