Docker Deployment Guide for AcelleMail

Containerised AcelleMail trades a small ergonomic cost (volumes, image rebuilds, patch flows) for dev/prod parity and clean image-tag rollback. This guide ships a production-ready Dockerfile (with all wizard-required PHP extensions baked in), a docker-compose.yml stack, and the operational recipes for patches, scaling, logs, and backups.

What this is for

Containerised AcelleMail trades a small ergonomic cost (you have to think about volumes, image rebuilds, and patch flows) for two big wins: parity between dev and prod environments, and clean rollback by image tag rather than by manual file restoration.

The fundamental trick: AcelleMail wasn't built container-first. The patch-upgrade flow assumes file mutations on the live filesystem (the /upgrade/run-file API replaces files in /var/www/acellemail in place). In Docker, this means the AcelleMail code lives in a named volume, not baked into the image — otherwise every patch would be wiped on the next docker compose up.

This guide ships a production-ready stack — a custom Dockerfile (so the wizard System Check passes), a docker-compose.yml, and the operational recipes for patches, scaling, logs, and backups.

👉 Bare-metal alternative: Install AcelleMail on Ubuntu 24.04 LTS — simpler if you're not already operating Docker.

Step 1 — Custom Dockerfile (the wizard fix)

The stock php:8.3-fpm image ships with only the bare PHP core — no imap, no sqlite3, no mailparse, no gd, no intl, no redis client. The AcelleMail install wizard's System Check hard-fails on any of those.

Save as php/Dockerfile:

FROM php:8.3-fpm AS base

# System libs needed by the PHP extensions below
RUN apt-get update && apt-get install -y --no-install-recommends \
        libpng-dev libjpeg62-turbo-dev libfreetype6-dev \
        libicu-dev libonig-dev libxml2-dev libzip-dev \
        libc-client-dev libkrb5-dev libsqlite3-dev libgmp-dev \
        zlib1g-dev libssl-dev pkg-config \
    && rm -rf /var/lib/apt/lists/*

# imap needs --with-imap-ssl=yes
RUN docker-php-ext-configure imap --with-kerberos --with-imap-ssl \
 && docker-php-ext-configure gd --with-freetype --with-jpeg \
 && docker-php-ext-install -j$(nproc) \
        pdo_mysql mysqli mbstring xml curl zip gd intl \
        imap gmp bcmath pdo_sqlite exif

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

# AcelleMail-specific php.ini tweaks (workload, not OS)
RUN { \
        echo 'memory_limit = 512M'; \
        echo 'upload_max_filesize = 300M'; \
        echo 'post_max_size = 300M'; \
        echo 'max_execution_time = 300'; \
    } > /usr/local/etc/php/conf.d/zz-acellemail.ini

# Composer (used during /upgrade/run-file patches)
RUN curl -fsSL https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

# Cron, supervisor, useful utilities
RUN apt-get update && apt-get install -y --no-install-recommends \
        cron supervisor unzip \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /var/www/acellemail

This image takes ~6 minutes to build first time, ~30 seconds with the apt + pecl layers cached. Tag it acellemail/php:8.3-fpm and push to your private registry, or build locally with docker compose build app worker scheduler.

Why not serversideup/php or webdevops/php-fpm? Both are excellent third-party images that include most of these extensions. The reason to roll your own is supply chain control — every byte in this Dockerfile is from PHP upstream or Debian main. Pin to specific versions if you're regulated.

Step 2 — The compose file

Save as /srv/acellemail/docker-compose.yml:

name: acellemail
services:
  app:
    build:
      context: .
      dockerfile: php/Dockerfile
    image: acellemail/php:8.3-fpm
    restart: unless-stopped
    working_dir: /var/www/acellemail
    volumes:
      - acellemail_code:/var/www/acellemail
    depends_on: [mysql, redis]
    networks: [internal]

  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - acellemail_code:/var/www/acellemail:ro
      - ./nginx/acellemail.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on: [app]
    networks: [internal, edge]

  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: acellemail
      MYSQL_USER: acellemail
      MYSQL_PASSWORD_FILE: /run/secrets/mysql_pw
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_pw
    volumes:
      - mysql_data:/var/lib/mysql
    secrets: [mysql_pw, mysql_root_pw]
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    networks: [internal]

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes: [redis_data:/data]
    networks: [internal]

  worker:
    image: acellemail/php:8.3-fpm
    restart: unless-stopped
    working_dir: /var/www/acellemail
    command: ["php", "artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
    volumes:
      - acellemail_code:/var/www/acellemail
    depends_on: [mysql, redis]
    deploy: { replicas: 2 }
    networks: [internal]

  scheduler:
    image: acellemail/php:8.3-fpm
    restart: unless-stopped
    working_dir: /var/www/acellemail
    entrypoint: ["sh", "-c"]
    command: ["while :; do php artisan schedule:run; sleep 60; done"]
    volumes:
      - acellemail_code:/var/www/acellemail
    depends_on: [mysql, redis]
    networks: [internal]

volumes: { acellemail_code: {}, mysql_data: {}, redis_data: {} }
networks: { internal: {}, edge: {} }
secrets:
  mysql_pw: { file: ./secrets/mysql_pw.txt }
  mysql_root_pw: { file: ./secrets/mysql_root_pw.txt }

Each design decision is a battle scar:

  • Code lives in a named volume. acellemail_code is shared read-write with app + worker + scheduler, read-only with nginx. The first docker compose up populates the volume from the unzipped install bundle (Step 3); patches mutate it in place.
  • Workers and scheduler are separate services. Mixing the queue worker into the FPM container is tempting and broken — when FPM restarts (image upgrade, OOM) the worker also dies, in-flight jobs become orphaned, and the scheduler stops firing. Separate services restart independently.
  • Two worker replicas. Same Small-tier sizing as bare-metal. Bump to 4 at Medium tier.
  • MySQL secrets via files, not env vars. Compose has supported file-backed secrets for years; using them is one less audit-log entry showing the password in plain text.
  • Nginx mounts code read-only. If PHP-FPM is compromised it can write the volume, but the public-facing nginx cannot.

The nginx config at nginx/acellemail.conf:

server {
    listen 80;
    server_name mail.example.com;
    root /var/www/acellemail/public;
    index index.php;
    client_max_body_size 300M;

    location / { try_files $uri $uri/ /index.php?$query_string; }
    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_read_timeout 300;
    }
    location ~ /\.(?!well-known).* { deny all; }

    # Certbot HTTP-01 challenge
    location /.well-known/acme-challenge/ { root /var/www/certbot; }
}

Step 3 — Initial install (one-time)

mkdir -p /srv/acellemail/{php,nginx,certbot/conf,certbot/www,secrets}
cd /srv/acellemail

# Drop in the Dockerfile, compose file, and nginx config from Steps 1-2

# Generate MySQL secrets
openssl rand -base64 32 > secrets/mysql_root_pw.txt
openssl rand -base64 32 > secrets/mysql_pw.txt
chmod 600 secrets/*.txt

# Build the custom PHP image
docker compose build app worker scheduler

# Bring up MySQL + Redis first, wait for healthy
docker compose up -d mysql redis
sleep 15

# Populate the code volume from the install zip
# (download acellemail-latest.zip from CodeCanyon to /srv/acellemail/ first)
docker run --rm -v acellemail_code:/dst -v $PWD:/src alpine \
  sh -c 'cd /dst && unzip -q /src/acellemail-latest.zip && chown -R 33:33 .'

# Bring up the rest
docker compose up -d

chown -R 33:33 is the critical one-time fix. The php:8.3-fpm image runs as UID 33 (www-data); without that ownership, AcelleMail can't write logs or cached views and the web installer loops forever.

Now browse to http://mail.example.com/install and run the wizard. With the custom Dockerfile from Step 1, all System Check rows should be green on the first try:

AcelleMail install wizard welcome page showing the 5-step top nav and the System Requirements card with green checkmarks

Full system requirements page — 14 PHP requirements all green plus 5 directory permission checks all green, with a Continue button at the bottom

Configuration step showing Site Name + License Key + Site Description fields plus an Admin Account card

For the Database step, use mysql as the host (the compose service name), acellemail as the database + user, and the contents of secrets/mysql_pw.txt as the password.

Step 4 — Add HTTPS

Run certbot in a one-shot container against the running nginx:

docker run --rm -it \
  -v /srv/acellemail/certbot/conf:/etc/letsencrypt \
  -v /srv/acellemail/certbot/www:/var/www/certbot \
  certbot/certbot certonly --webroot -w /var/www/certbot \
  -d mail.example.com --email you@example.com --agree-tos --non-interactive

Add the HTTPS server block to nginx/acellemail.conf (mirror the HTTP block, listen on 443 with ssl_certificate paths under /etc/letsencrypt/live/mail.example.com/) and force-redirect HTTP. Reload:

docker compose exec nginx nginx -s reload

Weekly renewal cron on the host:

0 3 * * 0 docker run --rm -v /srv/acellemail/certbot/conf:/etc/letsencrypt \
  -v /srv/acellemail/certbot/www:/var/www/certbot certbot/certbot renew --quiet \
  && docker compose -f /srv/acellemail/docker-compose.yml exec nginx nginx -s reload

Step 5 — Patch upgrade workflow (the Docker quirk)

API mode is the cleaner choice with Docker — it writes to the named volume and the change is live without a container rebuild. From any host with curl:

TOKEN="..."   # your AcelleMail API token
HOST="https://mail.example.com"
curl --max-time 900 -X POST "$HOST/api/v1/upgrade/run-file" \
  -H "Authorization: Bearer $TOKEN" \
  -F "patch=@/path/to/patch-latest.bin"

# Finalize from inside the app container so cache + migrations refresh
docker compose exec app php artisan migrate --force
docker compose exec app php artisan view:clear
docker compose exec app php artisan config:clear
docker compose restart app worker scheduler

The Acelle support handbook documents this restart requirement explicitly: opcache + Laravel's cached config will hold the old code path until the PHP process restarts. Always restart app, worker, scheduler together after any patch — partial restarts cause hard-to-debug version-mismatch behaviour.

Step 6 — Scaling

  • Vertical: bump the host's CPU + RAM and increase deploy.replicas on worker (4 at Medium, 8 at Large). Workers are stateless from each other's perspective — Laravel's queue driver (database or Redis) handles coordination.
  • Horizontal app: running multiple app replicas against the same code volume works for stateless requests (the admin UI, REST API) but Laravel session storage assumes a single writer by default — configure Redis-backed sessions in .env (SESSION_DRIVER=redis) before scaling app beyond 1 replica.
  • Database: at Medium tier and above, extract MySQL to a managed instance (RDS, DigitalOcean Managed DB, Hetzner Cloud DB). Update the DB_HOST in .env and remove the mysql service from the compose file.

Logging

Container stdout/stderr is captured by Docker's logging driver. For production, configure local driver with rotation at minimum — add to every service:

logging:
  driver: local
  options: { max-size: "20m", max-file: "5" }

For centralized logging, swap to loki, gelf, or push to a vector/fluentbit sidecar. Loki + Grafana is a popular self-hosted option; Datadog / Logtail / Better Stack are managed SaaS alternatives.

Backups

# Daily MySQL dump (host cron)
0 2 * * * docker compose -f /srv/acellemail/docker-compose.yml exec -T mysql \
  mysqldump --single-transaction --routines acellemail \
  | gzip > /srv/backups/acellemail-$(date +\%F).sql.gz

# Weekly volume snapshot (host cron)
0 3 * * 0 docker run --rm -v acellemail_code:/data -v /srv/backups:/backup \
  alpine tar czf /backup/acellemail_code-$(date +\%F).tar.gz -C /data .

Rotate retention with a separate find ... -mtime +30 -delete daily cleanup. For offsite copies, push the backup dir to S3 / Backblaze B2 / Wasabi using rclone sync or the storage provider's CLI.

Common issues

What you see Likely cause Fix
Wizard System Check red on IMAP or any other extension Using stock php:8.3-fpm instead of the custom Dockerfile Rebuild with docker compose build app worker scheduler --no-cache and re-up
Wizard System Check red on a storage/... permission row Volume was populated as root, not UID 33 Re-run the chown -R 33:33 from Step 3
Web installer keeps showing the welcome step after submitting The installed marker file can't be written docker compose exec app ls -la storage/app/installed — should be writable by UID 33
docker compose exec app php artisan ... says "could not find driver" Custom image build failed silently on a PECL extension Rebuild from scratch: docker compose build --no-cache app && docker compose up -d
MySQL container exits immediately Stale mysql_data volume from a previous root password docker compose down -v (deletes data!) then recreate, OR mount the old data and run mysql_upgrade
Patch upgrade leaves the wrong PHP version showing in admin Forgot to restart all 3 PHP services after patch docker compose restart app worker scheduler
Workers stop processing jobs after a few hours --max-time=3600 is hit; supervisor (in compose: restart: unless-stopped) should auto-restart Check docker compose ps — if a worker is exited, look at docker compose logs worker
nginx returns 502 immediately after docker compose up nginx started before php-fpm was listening Wait 10s and refresh; or add a healthcheck on app and depends_on: app: condition: service_healthy

FAQ

Can I bake the AcelleMail code into a custom image? Technically yes, but you lose the API-driven patch upgrade flow — every patch becomes a rebuild + redeploy + cache flush. The volume-based pattern in this guide keeps the upgrade flow simple.

What about Kubernetes? Same architecture, more YAML. Use a PersistentVolumeClaim for the code volume (RWX-capable storage class), separate Deployment resources for app + worker + scheduler, and an Ingress for nginx. Avoid running multiple app replicas against the same code volume unless you configure Redis-backed sessions.

Why two worker replicas? Throughput. One worker handles one queue job at a time. AcelleMail's send-campaign jobs spawn child jobs per chunk; two workers process them concurrently without overwhelming the upstream sending API.

Does this work on Docker Desktop? For development, yes. For production, no — Docker Desktop's networking and volume performance aren't production-grade. Use Linux + Docker Engine (or a managed container service like ECS, GKE, or Hetzner Container Service).

Network policy — restricting east-west traffic. The compose above puts app, worker, scheduler, mysql, redis on a shared internal network and nginx on internal + edge. To go further, split into per-service networks: app-db (app/worker/scheduler ↔ mysql/redis), edge (nginx ↔ app). A compromised nginx then cannot directly query MySQL. Worth doing on multi-tenant hosts.

Secret rotation. The compose secrets are file-backed. Rotate by: (1) openssl rand -base64 32 > secrets/mysql_pw.txt.new, (2) mv secrets/mysql_pw.txt.new secrets/mysql_pw.txt, (3) docker compose restart app worker scheduler (they pick up the new file at startup), (4) inside MySQL: ALTER USER 'acellemail'@'%' IDENTIFIED BY '<new value>';. Quarterly is reasonable. For shorter cadences, integrate with Hashicorp Vault or AWS Secrets Manager via a sidecar.

Why not run as root? The php:8.3-fpm image runs as www-data (UID 33) by default. Running as root inside a container is no safer than running as www-data — but it does mean any compromise has root-equivalent access if the container escapes. Stay with the default UID.

Related articles

6 条评论

3 条评论

  1. ahmed.hassan.c…
    For anyone using systemd-resolved (Ubuntu 22+): set DNSStubListener=no in /etc/systemd/resolved.conf before installing. Otherwise port 53 conflicts when you eventually run a bounce handler
  2. phuong.mai.hn
    Any reason to use MariaDB over MySQL 8? We default to MariaDB everywhere but I see most Acelle install guides use MySQL.
    1. admin
      We tested this with up to 1M subscribers on a $40/mo VPS. Past that you start needing query optimization. Below that, the defaults are fine.
    2. admin (已编辑)
      good question. the campaign:rerun audit writes to laravel.log only when the audit decides to force-resume — pure noop runs are silent. we'll add an info-level heartbeat in a future acelle release to make it easier to monitor fwiw
  3. aditi.s.bom
    Followed this on Ubuntu 24.04 last week. Zero issues. The php-imap and php-sqlite3 notes saved me a wizard-error round-trip.
    1. admin (已编辑)
      Appreciate it. If anything in this needs updating, ping us — we revisit articles every few months. fwiw

More in Installation & Setup