AcelleMail Post-Install Hardening Checklist

A fresh AcelleMail install is functionally complete the moment the web installer turns green — but it isn't yet production-grade. The default leaves easy-to-fix exposures: SSH password auth on, no fail2ban, no automated backups, no monitoring. This 22-control checklist closes each one in order. Run through it within the first hour of going live.

What this is for

A fresh AcelleMail install is functionally complete the moment the web installer turns green — but it isn't yet production-grade. The default configuration leaves several easy-to-fix exposures: SSH password auth on, no fail2ban, MySQL bound to 0.0.0.0 in some installs, no automated backups, no monitoring. This checklist closes each one in order. Run through it within the first hour of going live.

Each item lists the what, the why (so you can decide if it applies to your threat model), and the command. The whole list is ~30 minutes of work. None of it is theoretical — every entry has been the cause of a real incident in someone's AcelleMail deployment.

Where commands differ between distros, the Ubuntu/Debian version is shown first and a Rocky/AlmaLinux footnote follows. For Docker installs, see the Docker deployment guide — most of these controls still apply, just at the host or container boundary.

SSH — 4 controls

1. Disable root login

Why: root SSH is the #1 brute-force target. Any intruder with root credentials owns the box; with a non-root sudo user, they need both the password and to know which user is privileged.

sudo sed -i 's/^#*PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl reload ssh

2. Disable password authentication

Why: even a 16-character password is brute-forceable given enough time. SSH keys are not. Once you've copied your key (ssh-copy-id), turn passwords off.

sudo sed -i 's/^#*PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*ChallengeResponseAuthentication .*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*UsePAM .*/UsePAM no/' /etc/ssh/sshd_config
sudo systemctl reload ssh

Verify from a different terminal — never close your active SSH session before confirming the new config works.

3. Move SSH off port 22 (optional, noise-reduction)

Why: port 22 attracts the bulk of bot scanning. Moving to a non-standard port (e.g. 2200) cuts log noise and makes fail2ban's job easier. This is security through obscurity, but combined with the other controls, it's worthwhile.

sudo sed -i 's/^#*Port .*/Port 2200/' /etc/ssh/sshd_config
sudo ufw allow 2200/tcp
sudo systemctl reload ssh

Don't forget to update your laptop SSH config:

Host mail-server
  HostName mail.example.com
  Port 2200
  User acelle

4. Install fail2ban

Why: even with key auth + non-standard port, the SSH daemon will see thousands of probe attempts/day. fail2ban auto-bans IPs that fail multiple times.

sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local <<'CONF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true
port = 2200

[nginx-limit-req]
enabled = true
filter = nginx-limit-req
action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
logpath = /var/log/nginx/*error.log
maxretry = 10
findtime = 10m
bantime = 1h
CONF
sudo systemctl enable --now fail2ban

The nginx jail catches admin-panel brute-forcers (hitting /admin/login with a password list). The nginx-limit-req filter ships with fail2ban and pairs with the limit_req directive added in control 13 below.

Firewall — 1 control

5. Confirm UFW is on with the right rules

Why: AcelleMail only needs SSH + HTTP + HTTPS inbound. Anything else (MySQL on 3306, Redis on 6379, php-fpm on 9000 if TCP) should never be reachable from the internet.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 2200/tcp        # SSH (or 22 if you skipped control 3)
sudo ufw allow 'Nginx Full'    # 80 + 443
sudo ufw enable
sudo ufw status verbose

Rocky Linux: sudo firewall-cmd --permanent --add-service=ssh --add-service=http --add-service=https && sudo firewall-cmd --reload. Or follow the firewalld block in the Rocky install guide.

DigitalOcean / AWS: use the Cloud Firewall or Security Group as the primary edge filter; host-level UFW is then defense in depth. See the DO or AWS EC2 install guides.

MySQL — 3 controls

6. Bind MySQL to localhost

Why: MySQL bound to 0.0.0.0 is reachable from anywhere unless the firewall blocks it. If you forgot the firewall, your database is internet-exposed. Defense in depth.

# /etc/mysql/mysql.conf.d/mysqld.cnf (Ubuntu/Debian)
sudo grep -E '^bind-address' /etc/mysql/mysql.conf.d/mysqld.cnf
# Should show: bind-address = 127.0.0.1
# If it's 0.0.0.0, change it and:
sudo systemctl restart mysql

If your DB is on a separate host (RDS, DO Managed DB), bind-address is irrelevant — instead, the DB's security group should restrict to the application instance's IP.

7. Create a non-root DB user with minimum privileges

Why: the AcelleMail DB user only needs DML on its own database. If the install instructions had you create one with GRANT ALL (which the canonical install does for simplicity), narrow it to actual privileges in production.

-- Already created in install: 'acellemail'@'localhost' with ALL on acellemail.*
-- That's already locally-scoped, so this is mostly belt-and-suspenders.
-- For paranoid hardening, replace ALL with the minimum set:
REVOKE ALL PRIVILEGES ON acellemail.* FROM 'acellemail'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER,
      LOCK TABLES, CREATE TEMPORARY TABLES, REFERENCES
  ON acellemail.* TO 'acellemail'@'localhost';
FLUSH PRIVILEGES;

8. Take a baseline backup

Why: the first backup is the cheapest. After 6 months of campaigns, the cost (and the temptation to "just skip it this time") is much higher.

sudo apt install -y mysql-client
mysqldump --single-transaction --routines acellemail > ~/baseline-$(date +%F).sql

PHP — 2 controls

9. Disable dangerous PHP functions

Why: AcelleMail itself doesn't use exec/shell_exec/passthru/system — they're high-impact targets if a vulnerability lets an attacker write a webshell.

echo 'disable_functions = exec,shell_exec,system,passthru,popen,proc_open,parse_ini_file,show_source' | \
  sudo tee /etc/php/8.3/fpm/conf.d/99-disable-functions.ini
sudo systemctl restart php8.3-fpm

Verify AcelleMail still works after this. If anything in the admin breaks, narrow the list — proc_open is the one most likely to be needed by a Laravel app's diagnostic tooling. Keep proc_open enabled if php artisan commands stop working.

10. Hide PHP version

Why: expose_php = Off removes the X-Powered-By: PHP/8.3.x header. Doesn't stop a determined attacker (you can fingerprint version from behavior), but reduces casual reconnaissance.

sudo sed -i 's/^expose_php .*/expose_php = Off/' /etc/php/8.3/fpm/php.ini
sudo systemctl restart php8.3-fpm

Rocky Linux: path is /etc/php.ini (single file, no fpm/cli split).

Nginx — 3 controls

11. Hide nginx version

sudo sed -i '/^http {/a \    server_tokens off;' /etc/nginx/nginx.conf
sudo nginx -t && sudo systemctl reload nginx

12. Add security headers

Why: modern browsers enforce these — they're free defense against XSS, clickjacking, content-type sniffing, mixed-content downgrade.

Add to your AcelleMail server block:

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

CSP is more involved — AcelleMail's admin uses inline scripts in places, so a strict CSP needs testing. Start with Content-Security-Policy-Report-Only and a report-uri to learn what's actually emitted before enforcing.

13. Rate-limit admin login

Why: /admin/login is the highest-value brute-force target on the box. Rate limiting cuts attacker throughput dramatically.

# In http context (nginx.conf):
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

# In the AcelleMail server block, on the login location:
location = /admin/login {
    limit_req zone=login burst=10 nodelay;
    try_files $uri /index.php?$query_string;
}

5 requests/minute per source IP — enough for legitimate users, devastating for password sprayers.

File permissions — 2 controls

14. Storage + cache writable; everything else read-only

Why: if php-fpm is compromised, an attacker who can write /var/www/acellemail/public/index.php owns the site. Locking writes to storage/ and bootstrap/cache/ only prevents this.

sudo find /var/www/acellemail -type d -exec chmod 0755 {} \;
sudo find /var/www/acellemail -type f -exec chmod 0644 {} \;
sudo chmod -R 0775 /var/www/acellemail/storage /var/www/acellemail/bootstrap/cache
sudo chown -R www-data:www-data /var/www/acellemail

Rocky Linux: owner is nginx, not www-data. Also re-run the SELinux contexts from the Rocky install Step 6 after any mass chmodrestorecon may need to re-apply.

15. .env file readable only by web user

sudo chmod 0640 /var/www/acellemail/.env
sudo chown www-data:www-data /var/www/acellemail/.env

Application — 4 controls

16. Set APP_ENV=production and APP_DEBUG=false

Why: debug mode leaks framework state, including environment variables, on any uncaught exception. Production sites must never run with debug on.

sudo grep -E '^APP_(ENV|DEBUG)' /var/www/acellemail/.env
# Should show:  APP_ENV=production  /  APP_DEBUG=false

Adjust the .env, then cd /var/www/acellemail && sudo -u www-data php artisan config:clear.

17. Force HTTPS in .env

echo 'FORCE_HTTPS=true' | sudo tee -a /var/www/acellemail/.env

This makes route() and url() emit https:// even when behind a proxy that terminates TLS (Cloudflare, AWS ALB).

18. Generate a fresh APP_KEY

cd /var/www/acellemail && sudo -u www-data php artisan key:generate --force

Only run once, immediately after install. Re-running invalidates encrypted session cookies + cached queue payloads — everyone currently logged in is kicked out.

19. Configure 2FA for admin accounts

Why: even with all of the above, an admin password leak is plausible (phishing, credential reuse). 2FA closes the gap.

In AcelleMail Admin → Account Settings → Two-Factor Authentication → enable. Use Google Authenticator, 1Password, or any TOTP app. Save the recovery codes to a password manager immediately — there's no admin override to bypass 2FA if you lose both the device and the codes.

Backups + monitoring — 3 controls

20. Daily automated DB backup

sudo tee /etc/cron.daily/acellemail-db-backup <<'CRON'
#!/bin/bash
DEST=/var/backups/acellemail
mkdir -p $DEST
mysqldump --single-transaction --routines acellemail | gzip > $DEST/db-$(date +\%F).sql.gz
find $DEST -name 'db-*.sql.gz' -mtime +30 -delete
CRON
sudo chmod +x /etc/cron.daily/acellemail-db-backup

For off-site, add a restic or borg job that ships /var/backups/acellemail/ to S3 / Backblaze B2 / Wasabi. See Automated Database Backups for the full backup strategy.

21. Off-host log shipping (optional but recommended)

If you have it: ship /var/log/nginx/*.log, /var/log/auth.log, /var/www/acellemail/storage/logs/*.log to a SIEM (Datadog, Better Stack, Loki). The auth log especially — fail2ban is silent until you query it.

22. Uptime monitoring + bill alarms

External uptime check on https://mail.example.com/admin/login (UptimeRobot free tier is fine for non-critical, Better Uptime for serious). Plus a billing alarm at your hosting provider so a runaway egress bill can't surprise you — set this at 2× expected monthly spend.

Verification

After the 22 controls, run this audit:

# SSH — should be key-only, non-standard port, root denied
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|port'

# Firewall on, with the expected rules
sudo ufw status verbose

# MySQL bound to localhost
sudo grep '^bind-address' /etc/mysql/mysql.conf.d/mysqld.cnf

# fail2ban watching SSH + nginx
sudo fail2ban-client status

# AcelleMail in production mode
grep -E '^APP_(ENV|DEBUG)' /var/www/acellemail/.env

# Daily DB backup ran today (run again tomorrow to confirm)
ls /var/backups/acellemail/ | tail -5

If any of these is wrong, fix it before you publish the AcelleMail URL anywhere.

Common issues

What you see Likely cause Fix
Can't SSH in after control 2 Forgot to set up SSH keys before disabling password auth Console access via hosting provider; re-enable PasswordAuthentication, copy key, re-disable
php artisan commands fail with "proc_open() has been disabled" Control 9's disable list too aggressive Edit /etc/php/8.3/fpm/conf.d/99-disable-functions.ini to remove proc_open
Admin login still works without 2FA after enabling control 19 2FA is per-user; admin still has it disabled in their own account User must enable 2FA in their own Account Settings; admin can require it for all users via system-wide policy (settings dependent on version)
Rate-limit kicks legitimate users out (control 13) Burst of 10 too low for office NAT with many users Increase burst=10 to burst=30; or scope limit_req_zone by user agent + IP combo
Backups directory fills disk Retention -mtime +30 didn't run Manual: find /var/backups/acellemail/ -name '*.sql.gz' -mtime +30 -delete; debug why cron.daily didn't execute (grep CRON /var/log/syslog)
nginx -t fails after control 12 Duplicate add_header from base config nginx's add_header is "all-or-nothing per scope" — if any are defined in a parent scope, the child must re-declare them all. Move all add_header to one scope.
fail2ban not banning anything Default findtime too long or log path wrong sudo fail2ban-client status sshd — verify the jail is enabled and logpath points at the actual auth log

FAQ

Do I really need all 22? Yes. Each control closes a different category of attack — dropping any one widens the blast radius for that category. The whole list is ~30 minutes; skipping a step to save 90 seconds is a poor trade.

What about WAF / Cloudflare in front? A WAF (Cloudflare, AWS WAF) is a reasonable additional layer. It doesn't substitute for the 22 controls — defense in depth means both. Cloudflare also gives you free DDoS protection + a CDN for static assets. The trade-off: an extra dependency, plus the small operational cost of managing the WAF rules.

What if I'm running AcelleMail on Docker? Same checklist, applied at the container/host boundary. SSH controls apply to the host. nginx + PHP controls apply to the nginx/php-fpm containers. MySQL controls apply to the mysql container. The Docker deployment guide covers Docker-specific patterns (file-backed secrets, per-service networks, etc.).

How often should I re-audit? Annually at minimum. After any significant configuration change (new component added, sending volume crossed an order-of-magnitude threshold). And immediately after any disclosed CVE in nginx, PHP, MySQL, or AcelleMail itself.

What about GDPR / CAN-SPAM compliance? Hardening is necessary but not sufficient for regulatory compliance — those have content + process requirements beyond infrastructure. See GDPR Compliance Guide for Email Marketing and CAN-SPAM Compliance Checklist.

Can I script this whole checklist? Yes — Ansible / Terraform-userdata patterns are the typical approach. The reason this guide gives one-shot commands is because each control should be done consciously the first time, so you understand what it changes. Once you've done it once, write the Ansible role.

Related articles

15 bình luận

7 bình luận

  1. i.rossi.mil
    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
    1. admin
      Good tip. The Cloudflare-outbound-rate-limit case is something we hadnt documented.
  2. joel.anders.se
    Clean walkthrough. The supervisor config copy-paste worked first try.
  3. d.cohen.tlv
    Followed this on Ubuntu 24.04 last week. Zero issues. The php-imap and php-sqlite3 notes saved me a wizar-error round-trip.
    1. admin (đã chỉnh sửa)
      Glad it landed. Drop suggestions in the comments and we'll incorporate them on the next refresh tbh
    2. admin (đã chỉnh sửa)
      Thanks. Pass it along if it helps your team.
  4. aditi.s.bom
    Any reason to use MariaDB over MySQL 8? We default to MariaDB everywhere but I see most Acelle install guides use MySQL.
    1. admin
      Honest answer: it depends on your provider. SES handles it gracefully; Mailgun is stricter. Well add a provider-by-provider table in the next revision.
  5. danrey.dev
    Any reason to use MariaDB over MySQL 8? We default to ariaDB everywhere but I see most Acelle install guides use MySQL...
    1. admin
      We don't recommend that approach in production. It works in dev but has subtle race conditions under concurrent load. Stick with the documented pattern.
  6. akira.tnk88
    Installed on a $12/mo DigitalOcean droplet for our 30k-subscriber list. Performance has been fine. Memory peaks around 1.6 GB during batch sends; comfortable on 2GB.
    1. admin (đã chỉnh sửa)
      Thanks for the detail — adding the kernel-reboot edge case to the article on the next update.
    2. admin (đã chỉnh sửa)
      Thanks for the numbers. Worth pulling into a follow-up post on volume-tier sizing...
  7. anna.k.pm
    We use Hetzner instead of DO — same Ubuntu image, identical install. Probably $4/mo cheaper. The PTR record on Hetzner requires opening a support ticket but they respond same-day.
    1. admin (đã chỉnh sửa)
      Useful context. The fact that it took 3 weeks end-to-end is realistic; we sometimes get pushed to say 1-week timelines and they're not honest...

More in Installation & Setup