# 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.

- Canonical URL: https://acellesend.com/kb/articles/docker-deployment-guide-for-acellemail
- Author: Daniel Reyes, Technical Editor
- Category: Installation & Setup — Server requirements, deployment, Nginx/Apache, PHP config, SSL
- Content type: tutorial · Difficulty: advanced
- Tags: Acellemail
- Published: 2026-05-02 · Last updated: 2026-08-15
- Reading time: 13 min
- Publisher: AcelleMail Knowledge Base, https://acellesend.com/kb

---

## 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](https://acellesend.com/kb/articles/install-acellemail-on-ubuntu-2404)** — 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`:

```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`:

```yaml
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`:

```nginx
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)

```bash
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](https://acellesend.com/plugins/acelle/landing/images/help/kb/install/01-starting.png)

![Full system requirements page — 14 PHP requirements all green plus 5 directory permission checks all green, with a Continue button at the bottom](https://acellesend.com/plugins/acelle/landing/images/help/kb/install/02-system-check.png)

![Configuration step showing Site Name + License Key + Site Description fields plus an Admin Account card](https://acellesend.com/plugins/acelle/landing/images/help/kb/install/03-site-config.png)

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:

```bash
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:

```bash
docker compose exec nginx nginx -s reload
```

Weekly renewal cron on the host:

```cron
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`:

```bash
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:

```yaml
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

```bash
# 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

- [Install AcelleMail on Ubuntu 24.04 LTS](https://acellesend.com/kb/articles/install-acellemail-on-ubuntu-2404) — bare-metal alternative
- [Install AcelleMail on AWS EC2](https://acellesend.com/kb/articles/install-acellemail-on-aws-ec2) — for ECS/Fargate alternative
- [Server Requirements and Hosting Options](https://acellesend.com/kb/articles/server-requirements-and-hosting-options) — sizing reference
- [Post-Install Hardening Checklist](https://acellesend.com/kb/articles/post-install-hardening-checklist) — security follow-up
- [Setting Up Queue Workers and Cron Jobs](https://acellesend.com/kb/articles/setting-up-queue-workers-and-cron-jobs) — worker tuning concepts (apply to Docker `worker` service)
- [Redis for Queue Processing](https://acellesend.com/kb/articles/redis-for-queue-processing) — Redis tuning
- [Configuring Amazon SES with AcelleMail](https://acellesend.com/kb/articles/configuring-amazon-ses-with-acellemail) — first sending server after install
- [Scaling for 100K+ Emails Per Day](https://acellesend.com/kb/articles/scaling-for-100k-emails-per-day) — when to outgrow a single-host Docker stack

---

## Related articles

- [Install AcelleMail on AWS EC2](https://acellesend.com/kb/articles/install-acellemail-on-aws-ec2): This guide has moved. The install walkthrough for any server, an AWS EC2 instance included, is Install AcelleMail and Get Started.
- [Server Requirements and Hosting Options for AcelleMail](https://acellesend.com/kb/articles/server-requirements-and-hosting-options): The right server for AcelleMail is dictated by send volume, not subscriber count. A list of 5M subscribers mailed monthly needs less compute than a list of 100k mailed daily. This article gives concrete specs at four volume tiers, a current 2026 provider comparison, and the operational considerations that determine your real cost.
- [Securing Your AcelleMail Install: A Post-Install Checklist](https://acellesend.com/kb/articles/post-install-hardening-checklist): Your AcelleMail install works the moment the web installer turns green — but two things now live on your server that didn't before: your subscribers' personal data, and your domain's sending reputation. This checklist secures both, in priority order, without turning you into a sysadmin. Most of it is config you set once.
- [Install AcelleMail on Ubuntu 24.04 LTS](https://acellesend.com/kb/articles/install-acellemail-on-ubuntu-2404): This guide has moved. The install walkthrough for any server, an Ubuntu 24.04 server included, is Install AcelleMail and Get Started.
- [Installing AcelleMail on DigitalOcean](https://acellesend.com/kb/articles/installing-acellemail-on-digitalocean): This quickstart has moved. The install walkthrough for any server, a DigitalOcean droplet included, is Install AcelleMail and Get Started.
