Less attack surface, more control
A Magento production server is only as secure as its least hardened service. This article shows, hands-on, how to systematically shrink the attack surface by disabling unneeded services, automating security updates, enforcing non-root execution, correcting file permissions, running fail2ban, and hardening SSH, with a direct tie-in to the Mark Shust docker-magento setup and typical Debian or Ubuntu production environments.
Table of Contents
- 1. Minimal attack surface: reducing services, ports, and packages
- 2. Automatic security updates: unattended-upgrades and dnf-automatic
- 3. Running application processes without root privileges
- 4. Auditing filesystem permissions for the Magento webroot
- 5. Intrusion prevention with fail2ban against brute-force attacks
- 6. SSH hardening: key-only auth, no root login, alternate port
- 7. Firewall basics with ufw as the last line of defense
- 8. A practical hardening checklist for the Magento production server
- 9. Server hardening compared directly
- 10. Summary
- 11. FAQ
1. Minimal attack surface: reducing services, ports, and packages
The principle of minimal attack surface is simple: every running service, every open port, and every installed package is a potential attack vector, regardless of whether it is currently used. A freshly provisioned Debian or Ubuntu server image ships by default with services like avahi-daemon, cups, rpcbind, or a full mail transport agent, none of which a pure Magento production server ever needs. Each of these services runs with its own privileges, potentially opens a network port, and must be patched independently of the core stack, which increases maintenance load without delivering any functional benefit.
The practical starting point is a stock-take: systemctl list-unit-files --state=enabled shows every actively started service, ss -tulpn lists every socket actually listening along with its owning process. A Magento production server typically only needs nginx, php-fpm, a database connection, optionally Redis and OpenSearch either local or as separate hosts, and SSH for administration. Everything else should be disabled, masked, and, where possible, fully uninstalled, because a masked service cannot be accidentally reactivated by a package update.
#!/usr/bin/env bash
# harden-attack-surface.sh - disable unused services and audit listening ports
set -euo pipefail
echo "[INFO] Currently enabled services"
systemctl list-unit-files --state=enabled
# Disable and mask services not required on a Magento production host
for svc in avahi-daemon cups rpcbind bluetooth ModemManager; do
if systemctl list-unit-files | grep -q "^${svc}.service"; then
systemctl disable --now "${svc}.service"
systemctl mask "${svc}.service"
echo "[OK] Disabled and masked ${svc}"
fi
done
echo "[INFO] Listening TCP/UDP sockets and owning processes"
ss -tulpn
# Remove packages that are no longer required after disabling services
apt-get purge -y avahi-daemon cups-common rpcbind || true
apt-get autoremove -y --purge
2. Automatic security updates: unattended-upgrades and dnf-automatic
Automatic security updates close the window between the release of a patch and its actual installation, a window that often spans weeks or months under manual maintenance. On Debian and Ubuntu systems, the unattended-upgrades package handles this, configured via /etc/apt/apt.conf.d/50unattended-upgrades and enabled via /etc/apt/apt.conf.d/20auto-upgrades. On RHEL-based systems such as AlmaLinux or Rocky Linux, dnf-automatic fills the same role, controlled through /etc/dnf/automatic.conf together with the systemd timer dnf-automatic-install.timer. Both tools can be configured so that only packages flagged as security-relevant from the respective security repository get installed automatically, while regular feature updates stay manual.
The core tradeoff on a production system is the balance between patch speed and stability. An automatic update of PHP, nginx, or MySQL to a new minor version can, in rare cases, change behavior that Magento or an extension depends on, causing an outage at exactly the wrong moment. It therefore makes sense to strictly limit Unattended-Upgrade::Origins-Pattern to the security origin, exclude critical packages such as the database server via a blacklist, and enable notification emails for every automatic installation. A reboot after kernel updates should never happen automatically, but should be tied to a scheduled maintenance window via Unattended-Upgrade::Automatic-Reboot "false".
The same principle applies to dnf-automatic: the upgrade_type = security parameter in /etc/dnf/automatic.conf restricts installations to security-relevant packages only, while apply_updates = yes should only be enabled after careful testing in a staging environment. On a freshly provisioned production system, it is worth running notification-only mode for two to three weeks first, to observe which packages would regularly be affected before actually flipping automatic installation live.
3. Running application processes without root privileges
The principle of least privilege applies not only to people but equally to processes. nginx starts as root to bind ports 80 and 443, but immediately drops those privileges and runs the actual worker processes under an unprivileged user such as www-data, configured via the user directive in nginx.conf. php-fpm follows the same pattern through the pool parameters user and group in the pool configuration under /etc/php/8.4/fpm/pool.d/. If a php-fpm pool accidentally runs as root, any code executed through a vulnerability in Magento or an extension automatically inherits full system privileges instead of being confined to the bare minimum.
In a Docker context, such as the Mark Shust setup, the same rule applies to containers: a container whose main process runs as root makes a container escape significantly more valuable to an attacker, because it lands with root privileges directly on the host. Official PHP and nginx images support the user directive in docker-compose.yml to explicitly bind the container process to an unprivileged UID/GID value. The deploy user with SSH access to the production server should likewise never be root, but a dedicated account with tightly scoped sudo privileges limited to only the commands actually needed, such as cache flushing or deployment scripts, logged through sudo logging.
# docker-compose.yml excerpt (Mark Shust docker-magento style)
# Run the phpfpm container as a dedicated non-root user instead of the image default
services:
phpfpm:
image: markoshust/magento-php:8.4-fpm-1
user: "1000:1000" # dedicated deploy UID/GID, never 0:0
volumes:
- ./:/var/www/html:cached
environment:
- PHP_MEMORY_LIMIT=2G
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
4. Auditing filesystem permissions for the Magento webroot
Magento filesystem permissions follow a simple pattern that, in practice, still gets implemented incorrectly all too often: the deploy user owns the entire webroot, and the web server process user only gets write access where Magento actually needs to write at runtime. Directories should generally get 750 and files 640, so that the owning group can read but nobody outside can write. A blanket chmod -R 777, still circulated in older Magento forum threads, opens write access to the entire codebase to every local process and, in the worst case, to every web request.
Write access for the web server user is only needed for var/, generated/, pub/media/, and pub/static/, because Magento stores cache, compiled code, product images, and static assets there at runtime. Crucially, pub/media/ must be writable but never executable: an upload form that writes image files to pub/media/ must never allow a .php file placed there to be interpreted by the web server. This is enforced beyond the filesystem layer through an nginx location rule that blocks PHP execution inside pub/media/ regardless of file permissions.
SSH keys are subject to their own, stricter rule: ~/.ssh must be 700, authorized_keys and private keys 600, otherwise sshd refuses the login for good reason, because overly permissive rights mean other local users could read or tamper with the key. A regular, automated audit of these permissions catches drift that quietly creeps in over months through manual interventions.
#!/usr/bin/env bash
# audit-magento-permissions.sh - check Magento webroot ownership and modes
set -euo pipefail
WEBROOT="/var/www/html"
DEPLOY_USER="deploy"
WEB_GROUP="www-data"
echo "[CHECK] Base ownership of the webroot"
find "${WEBROOT}" -maxdepth 1 -not -user "${DEPLOY_USER}" -print
echo "[CHECK] Directories must not exceed 750"
find "${WEBROOT}" -type d -perm /027 -print
echo "[CHECK] Files must not exceed 640"
find "${WEBROOT}" -type f -perm /137 -print
echo "[CHECK] pub/media must never be executable"
find "${WEBROOT}/pub/media" -type f -perm /111 -print
echo "[CHECK] var/ and generated/ must be writable by the web server group"
stat -c "%U:%G %a %n" "${WEBROOT}/var" "${WEBROOT}/generated"
echo "[CHECK] SSH key permissions for the deploy user"
stat -c "%a %n" "/home/${DEPLOY_USER}/.ssh" "/home/${DEPLOY_USER}/.ssh/authorized_keys"
5. Intrusion prevention with fail2ban against brute-force attacks
fail2ban watches log files in real time for recurring patterns of failed login attempts and, after a configurable number of failures within a time window, automatically imposes a temporary firewall ban on the source IP, enforced via iptables, nftables, or directly through ufw. For SSH, the sshd jail comes preconfigured in nearly every distribution and only needs to be enabled in /etc/fail2ban/jail.local, where maxretry, findtime, and bantime can be tuned to the organization's own risk profile.
There is no built-in jail for the Magento admin area, because fail2ban has no knowledge of Magento's application logic. What is needed is a custom filter that evaluates the nginx access log or a dedicated Magento log for failed POST requests against the admin login route, typically recognizable by repeated responses against the same randomly generated admin path without a subsequent successful session. Since the admin path on a properly configured installation is not /admin anyway, but a randomly generated value from admin/url/use_custom_path, this measure combines two layers of defense: rate limiting through fail2ban and obfuscation of the actual login path.
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
port = ssh
maxretry = 4
bantime = 2h
[magento-admin-auth]
enabled = true
port = http,https
filter = magento-admin-auth
logpath = /var/www/html/var/log/system.log
/var/log/nginx/access.log
maxretry = 5
findtime = 10m
bantime = 3h
action = %(action_mwl)s
# /etc/fail2ban/filter.d/magento-admin-auth.conf
[Definition]
failregex = ^.*"POST /admin_[a-z0-9]+/admin/index/index.*" 200 .* "<HOST>".*$
ignoreregex =
6. SSH hardening: key-only auth, no root login, alternate port
The single most effective SSH measure is disabling password authentication entirely: PasswordAuthentication no and PubkeyAuthentication yes in /etc/ssh/sshd_config force every login to require a previously registered private key. Brute-force attacks against passwords, which make up the bulk of automated SSH scanning, run into a dead end as a result, regardless of how weak any individual user's password might actually be. In addition, PermitRootLogin no should be set, so that an attacker with even a valid root password cannot gain direct root access over SSH, but must first log in as a regular user and then explicitly escalate via sudo, which gets logged as well.
Changing the default port from 22 to something else is often overrated and should be treated purely as an additional, secondary measure, never as a primary control. Its main effect is drastically reducing the noise from automated mass scanners in the logs, which makes manually reviewing genuine login attempts easier. A targeted attacker who already has a specific target in view will find an open port through a full port scan within minutes, which is why an alternate port never replaces key-only auth or fail2ban, only complements them. It is also important to configure modern Ciphers and KexAlgorithms lists as well as a low MaxAuthTries.
#!/usr/bin/env bash
# harden-sshd.sh - apply key-only, non-root SSH hardening and validate before reload
set -euo pipefail
SSHD_CONFIG="/etc/ssh/sshd_config"
cp "${SSHD_CONFIG}" "${SSHD_CONFIG}.bak.$(date +%s)"
# Enforce key-only authentication, no root login, low retry budget
sed -i \
-e 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' \
-e 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' \
-e 's/^#\?PermitRootLogin.*/PermitRootLogin no/' \
-e 's/^#\?MaxAuthTries.*/MaxAuthTries 3/' \
"${SSHD_CONFIG}"
# Defense-in-depth only: move off the default port after key-only auth is confirmed working
grep -q '^Port ' "${SSHD_CONFIG}" || echo "Port 2222" >> "${SSHD_CONFIG}"
echo "[CHECK] Validating configuration syntax before reload"
sshd -t
echo "[OK] Reloading sshd with hardened configuration"
systemctl reload sshd
7. Firewall basics with ufw as the last line of defense
A firewall is the last line of defense in case one of the previous measures fails, or a service accidentally ends up listening on a public interface after all. ufw (Uncomplicated Firewall) is the pragmatic front end over iptables or nftables on Debian and Ubuntu systems and should be configured with a restrictive default posture: incoming traffic denied by default, outgoing allowed, and only the ports actually needed explicitly opened, typically 80 and 443 for HTTP/HTTPS plus the SSH port. ufw limit ssh complements fail2ban with a simple, kernel-level rate limit that already kicks in before fail2ban's log evaluation.
A frequently overlooked problem on Docker hosts like the Mark Shust setup is that Docker inserts its own iptables rules into a separate chain and, in doing so, can bypass ufw rules, so a container port remains publicly reachable despite an active ufw block. Anyone running Docker on a hardened host should explicitly restrict port bindings in docker-compose.yml to 127.0.0.1 wherever direct external access is not needed, and additionally make use of the DOCKER-USER iptables chain to fold Docker containers into ufw's firewall logic deliberately, instead of blindly trusting the default ufw rules.
8. A practical hardening checklist for the Magento production server
Server hardening is not a one-time setup script but a recurring process that has to be run again on every new server, every migration, and every major update. For a Mark Shust-based docker-magento setup, the following order is recommended: first establish the minimal attack surface, then enable automatic security updates, next verify that all container and system processes run without root, then audit the filesystem permissions in the webroot, followed by fail2ban jails for SSH and admin login, SSH hardening, and finally the ufw firewall as the closing control layer. This order ensures that every measure builds on an already secured base.
To prevent hardening measures from silently drifting over months, for instance because a manual change loosens file permissions again or a new container accidentally starts as root, it is worth automating the audit scripts through a cron job or a CI pipeline that regularly checks against a documented baseline. Configuration management tools such as Ansible additionally make the entire hardening process reproducible and version-controlled, so that a newly provisioned server reaches exactly the same security posture as the existing one, instead of relying on manually maintained documentation that in practice regularly goes stale.
9. Server hardening compared directly
The following overview contrasts insecure default configurations with the corresponding hardening measures described for a typical Magento production server in the previous sections.
| Area | Insecure behavior | Secure countermeasure | Effect |
|---|---|---|---|
| Process privileges | php-fpm/nginx runs as root | Dedicated non-root user with minimal privileges | Limits damage from remote code execution |
| SSH access | Password login and root login allowed | Key-only auth, PermitRootLogin no | Prevents brute force and direct root access |
| Patch management | No automatic security updates | unattended-upgrades / dnf-automatic active | Closes known gaps promptly |
| Filesystem permissions | var/, generated/, pub/media set to 777 | Correct permissions: 750/640 following least privilege | Prevents web shell persistence |
| Brute-force protection | No intrusion prevention against brute force | fail2ban jails for SSH and Magento admin login | Automatic ban after repeated failures |
None of these measures replace the others: non-root processes limit the damage after a successful compromise, SSH hardening and fail2ban prevent many compromises from happening in the first place, and automatic updates close the gaps that would otherwise get exploited to begin with. Only the interplay of all five layers produces a production system that stays robust against the realistic, broadly automated attack patterns found across the internet.
Mironsoft
Server hardening audits and infrastructure security for Magento production systems
Want your Magento server professionally hardened?
We audit your server configuration, harden SSH and filesystem permissions, set up fail2ban and automatic security updates, and document the entire configuration in a reproducible way.
Server audit
Full review of services, ports, process privileges, and filesystem permissions
SSH & firewall
Key-only access, ufw rules, and fail2ban jails for SSH and Magento admin
Automation
Reproducible hardening via Ansible instead of manually maintained documentation
10. Summary
Server hardening is the sum of many small, unspectacular measures that together drastically reduce the realistic attack surface of a Magento production server. A minimal base installation without unnecessary services and ports, automatic security updates with carefully chosen exceptions, consistent non-root execution of every application process, and correct filesystem permissions form the foundation. None of these measures is spectacular on its own, but each one closes a real, actively exploited class of attack.
fail2ban and SSH hardening extend this foundation with active defense against brute-force attacks, while a ufw firewall acts as the last control layer in case a service ever gets accidentally exposed after all. Anyone who treats these measures not as a one-time setup but as a recurring, automated process, made reproducible through tools like Ansible, runs a Magento production server that holds up durably against the common automated attack patterns found across the internet.
Server Hardening: The key takeaways
Minimize attack surface
Consistently disable and remove unnecessary services, ports, and packages, check ss -tulpn regularly.
Enforce non-root
php-fpm, nginx, and Docker containers run under a dedicated, unprivileged user instead of root.
SSH & fail2ban
Key-only auth, PermitRootLogin no, fail2ban jails for SSH and Magento admin login.
Automated updates
unattended-upgrades or dnf-automatic restricted to the security origin, with email notifications.