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.
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#
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 it materially reduces noise — and 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 hosts file or SSH config: Host mail-server\n HostName mail.example.com\n Port 2200.
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).
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 #3)
sudo ufw allow 'Nginx Full' # 80 + 443
sudo ufw enable
sudo ufw status verbose
For Rocky Linux: firewall-cmd --permanent --add-service=ssh --add-service=http --add-service=https && firewall-cmd --reload.
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)
# Confirm: bind-address = 127.0.0.1
sudo grep -E '^bind-address' /etc/mysql/mysql.conf.d/mysqld.cnf
# If it's 0.0.0.0, change it to 127.0.0.1 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 most do), narrow it to actual privileges.
-- 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.
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
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.
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
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 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.
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.
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.
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/B2/Wasabi. See the backup strategy cookbook.
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.
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 running
ls /var/backups/acellemail/ | tail -5
If any of these is wrong, fix it before you publish the AcelleMail URL anywhere.
Related reading#
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, etc.) 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 instead of the OS. 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.
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.