Skip to content

Instantly share code, notes, and snippets.

@olragon
Created March 13, 2026 15:06
Show Gist options
  • Select an option

  • Save olragon/4d923d360b98a39b1cacacc0191de8a6 to your computer and use it in GitHub Desktop.

Select an option

Save olragon/4d923d360b98a39b1cacacc0191de8a6 to your computer and use it in GitHub Desktop.
Mautic 5 Docker Setup Guide — single container with nginx, PHP-FPM, cron, CKEditor build, and Remix Icons

Mautic 5 Docker Setup Guide

Complete guide to running Mautic 5.x in a single Docker container with nginx, PHP-FPM, and cron — designed for low-traffic marketing automation (< 50k contacts).

Architecture

Reverse Proxy (Traefik/nginx) → Container (nginx:80 → PHP-FPM:9000)
                                       ↓
                                 External MariaDB 11+

Single container runs three processes via supervisord:

  • nginx — serves static assets + proxies PHP to FPM
  • PHP-FPM — handles Mautic requests (TCP 9000)
  • cron — runs Mautic scheduled tasks (segments, campaigns, emails)

Resource usage (< 50k contacts): ~100–150 MB RAM, < 0.1% CPU idle.

Dockerfile

FROM php:8.3-fpm-bookworm

# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
    nginx cron supervisor \
    libpng-dev libjpeg62-turbo-dev libfreetype6-dev \
    libzip-dev libicu-dev libbz2-dev libxml2-dev \
    libc-client-dev libkrb5-dev libldap2-dev \
    libmagickwand-dev libexif-dev \
    unzip mariadb-client \
    && rm -rf /var/lib/apt/lists/*

# PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-configure imap --with-kerberos --with-imap-ssl \
    && docker-php-ext-install -j$(nproc) \
        bcmath bz2 gd imap intl mysqli pdo_mysql \
        opcache soap zip ldap sockets exif

# PECL extensions
RUN pecl install igbinary redis imagick \
    && docker-php-ext-enable igbinary redis imagick

# PHP config
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
COPY php-overrides.ini /usr/local/etc/php/conf.d/99-mautic.ini

# PHP-FPM: TCP 9000, tuned for low-traffic (3 children × 256MB = 768MB ceiling)
RUN sed -i \
    -e 's/pm.max_children = 5/pm.max_children = 3/' \
    -e 's/pm.start_servers = 2/pm.start_servers = 1/' \
    -e 's/pm.min_spare_servers = 1/pm.min_spare_servers = 1/' \
    -e 's/pm.max_spare_servers = 3/pm.max_spare_servers = 2/' \
    /usr/local/etc/php-fpm.d/www.conf

# Nginx config
RUN rm -f /etc/nginx/sites-enabled/default
COPY nginx.conf /etc/nginx/sites-enabled/mautic.conf

# Cron
COPY crontab /etc/cron.d/mautic
RUN chmod 0644 /etc/cron.d/mautic

# Supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# Mautic source code
COPY html/ /var/www/html/

# Build frontend assets (CKEditor + compiled CSS with Remix Icons)
# Node is only needed at build time — removed after
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg \
    && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
    && apt-get install -y --no-install-recommends nodejs \
    && cd /var/www/html \
    && npm install --include=dev \
    && npx webpack --mode production --config webpack.config.js \
    && php -d memory_limit=512M bin/console mautic:assets:generate --env=prod --no-interaction \
    && rm -rf node_modules /root/.npm \
    && apt-get purge -y nodejs \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

RUN chown -R www-data:www-data /var/www/html
WORKDIR /var/www/html
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

Why the Node.js build step?

Mautic 5 switched to CKEditor 5 (TypeScript, needs webpack) and Remix Icon (needs LESS compilation). Without running mautic:assets:generate:

  • CKEditor returns 404 — only TypeScript source exists at /app/assets/libraries/ckeditor/src/
  • All sidebar icons are invisible — Remix Icon @font-face is missing from compiled /media/css/libraries.css

The command combines each bundle's Assets/css/* and Assets/js/* into production files in /media/css/ and /media/js/.

Nginx Configuration

server {
    listen 80;
    server_name _;
    root /var/www/html;
    index index.php index.html;
    client_max_body_size 64M;

    # Mautic tracking pixel and JS — virtual URLs handled by PHP
    location = /mtc.js { rewrite ^ /index.php last; }
    location = /mtc    { rewrite ^ /index.php last; }

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        fastcgi_read_timeout 300;
    }

    # IMPORTANT: Allow font files from bundle Assets directories
    # Compiled CSS references fonts via relative paths:
    #   url(../../app/bundles/CoreBundle/Assets/css/libraries/remixicon/fonts/remixicon.woff2)
    # Without this rule, the deny block below returns 403 for all icon fonts
    location ~* /app/bundles/.+/Assets/.+\.(woff2?|ttf|eot|svg|otf)$ {
        expires 1y;
        access_log off;
        add_header Cache-Control "public, immutable";
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        access_log off;
        add_header Cache-Control "public, immutable";
    }

    location ~ /\. { deny all; }

    # Block sensitive directories — MUST anchor with ^/
    # Without ^, routes like /s/config/edit match "config" → 403
    location ~* ^/(app|bin|config|vendor|plugins|misc|node_modules)/ {
        deny all;
    }
}

Nginx Gotchas

Problem Cause Fix
Icons return 403 Deny rule blocks /app/ path where fonts live Add font-allow rule before deny rule
/s/config/edit returns 403 Unanchored /(config) matches Mautic routes Anchor with ^/
Tracking pixel 404 try_files looks for nonexistent /mtc file Add explicit rewrite rules
PHP-FPM connection refused Unix socket fails under supervisord Use TCP 127.0.0.1:9000

Docker Compose

services:
  mautic:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: mautic
    restart: unless-stopped
    ports:
      - "8085:80"
    networks:
      - default
      - proxy          # Traefik/reverse proxy network
      - db-network     # External MariaDB access
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.mautic.rule=Host(`mautic.example.com`)"
      - "traefik.http.routers.mautic.entrypoints=https"
      - "traefik.http.routers.mautic.tls=true"
      - "traefik.http.services.mautic.loadbalancer.server.port=80"
      - "traefik.http.routers.mautic-http.rule=Host(`mautic.example.com`)"
      - "traefik.http.routers.mautic-http.entrypoints=http"
      - "traefik.http.routers.mautic-http.middlewares=redirect-to-https"
      - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
    volumes:
      - mautic-config:/var/www/html/config
      - mautic-media:/var/www/html/media
      - mautic-plugins:/var/www/html/plugins
      - mautic-themes:/var/www/html/themes
      - mautic-var:/var/www/html/var
      - mautic-translations:/var/www/html/translations
    environment:
      - MAUTIC_DB_HOST=your-mariadb-container
      - MAUTIC_DB_NAME=mautic
      - MAUTIC_DB_USER=mautic
      - MAUTIC_DB_PASSWORD=${MAUTIC_DB_PASSWORD}

networks:
  proxy:
    external: true
  db-network:
    external: true

volumes:
  mautic-config:
  mautic-media:
  mautic-plugins:
  mautic-themes:
  mautic-var:
  mautic-translations:

Volume Notes

  • configlocal.php with DB/mailer/site settings
  • media — uploads + compiled assets (may need refresh after image rebuild)
  • var — cache, logs, sessions
  • themes — email/landing page templates

Caveat: The media volume persists old compiled CSS across rebuilds. After upgrading, either clear the volume or run mautic:assets:generate in the new container.

Crontab

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

*/5  * * * *  www-data  php /var/www/html/bin/console mautic:segments:update --no-interaction >> /var/log/mautic-cron.log 2>&1
*/10 * * * *  www-data  php /var/www/html/bin/console mautic:campaigns:update --no-interaction >> /var/log/mautic-cron.log 2>&1
*/15 * * * *  www-data  php /var/www/html/bin/console mautic:campaigns:trigger --no-interaction >> /var/log/mautic-cron.log 2>&1
*/15 * * * *  www-data  php /var/www/html/bin/console mautic:messages:send --no-interaction >> /var/log/mautic-cron.log 2>&1
0    * * * *  www-data  php /var/www/html/bin/console mautic:broadcasts:send --no-interaction >> /var/log/mautic-cron.log 2>&1

Critical:

  • PATH is required — Docker cron doesn't inherit the container's PATH
  • Run as www-data — running as root creates cache files PHP-FPM can't read
  • mautic:messages:send — renamed from mautic:emails:send in Mautic 5
  • Don't add messenger:consume email — only for async transport; Mautic defaults to SyncTransport

Supervisord

[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0

[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:cron]
command=/usr/sbin/cron -f
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

PHP Overrides

memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
date.timezone = UTC

; OPcache
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0

Optional: OPcache JIT

For significant performance boost, add:

opcache.jit = 1255
opcache.jit_buffer_size = 64M
opcache.interned_strings_buffer = 32
opcache.enable_file_override = On
opcache.file_cache = /tmp/opcache

Note: jit_buffer_size=0 (default) disables JIT entirely, even with jit=tracing.

MariaDB Tuning

For databases > 1GB (Mautic's email_stats table grows fast):

innodb_buffer_pool_size = 512M    # Default 128M
join_buffer_size = 4M             # Default 256K — Mautic has complex joins
table_open_cache = 400            # Default 64 — Mautic has 109+ tables
tmp_table_size = 64M              # Default 16M
max_heap_table_size = 64M         # Must match tmp_table_size
innodb_log_file_size = 128M       # Default 48M

Upgrade Path (3.x → 5.x)

  1. Backup DB + files
  2. Upgrade incrementally: 3.x → 3.3.5 → 4.4.13 → 5.2.x
  3. Run migrations: php bin/console doctrine:migrations:migrate --no-interaction
  4. Replace themes — Mautic 5 requires "builder": ["grapesjsbuilder"] in config.json
  5. Clear cache as www-data: docker exec -u www-data mautic php -d memory_limit=512M bin/console cache:clear --env=prod
  6. Regenerate assets: docker exec -u www-data mautic php bin/console mautic:assets:generate
  7. Purge CDN cache if behind Cloudflare or similar

Troubleshooting

Issue Solution
Icons missing after deploy Run mautic:assets:generate; purge CDN cache
CKEditor 404 Check /media/libraries/ckeditor/ckeditor.js exists; run webpack if not
403 on admin pages Check nginx deny rule anchoring (^/)
Cache permission errors Always clear cache as www-data, never root
Cron not running Check PATH in crontab; verify cron -f in supervisord
Slow after upgrade Restart container (clears OPcache); check MariaDB slow log

Cloudflare Notes

If Mautic is behind Cloudflare:

  • SSL mode must be "Full" — "Flexible" sends HTTP to origin, Traefik HTTPS router gets 504
  • CF caches 404s — after fixing missing assets, purge zone cache
  • ACME HTTP-01 certs fail behind CF proxy — use CF "Full" with self-signed, or DNS-01 challenge
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment