Self-hosted email marketing with full source code. Pay once, own forever. Get AcelleMail — $74 →

AcelleMail Post-Install Hardening Checklist

A 22-point hardening checklist to apply right after a fresh AcelleMail install — SSH, firewall, fail2ban, MySQL, PHP, file permissions, backup, monitoring — with copy-paste commands and the "why" for each.

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.

More in Installation & Setup