Production Security And Server Hardening
Configuration Hardening And Service Least Privilege
Dedicated Users And File Permissions
Every service runs as its own unprivileged user: php-fpm pool workers as an application user, nginx workers as www-data, the database as its own account. The sharpest edge for PHP is write access to code. The deployed application should be owned by a deploy user, readable — not writable — by the php-fpm user, with writable paths limited to the specific directories the app genuinely needs: storage, cache, uploads. When an attacker finds an upload flaw, "can they write a .php file somewhere the web server will execute it?" is the question these permissions answer.
/var/www/app deploy:app-run r-x code: readable, never writable
/var/www/app/storage app-run rwx writable, and non-executable to the web server
The upload directory pairs with proxy config: nginx should refuse to execute PHP from writable paths (location ^~ /uploads { ... } with no PHP handler). Writable or executable — never both — is the rule of thumb.
Systemd Sandboxing
Modern systemd units offer per-service confinement that costs one config block:
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/www/app/storage
PrivateTmp=yes
ProtectSystem=strict makes the entire filesystem read-only to the service except the paths you name; NoNewPrivileges blocks privilege escalation via setuid binaries; PrivateTmp isolates the shared temp directory that so many local exploits traverse. Apply these to queue workers and custom daemons first — they are usually the least-reviewed processes on the box. systemd-analyze security <unit> scores a unit's exposure and is a fine worklist generator.
PHP-Level Configuration
php.ini hardening follows the same principle. expose_php = Off stops advertising the version. display_errors = Off in production keeps stack traces out of responses (errors go to logs). open_basedir and disable_functions (dropping exec, shell_exec, system, and friends when the app does not shell out) are imperfect controls — bypasses exist — but they raise the cost of turning a code-injection foothold into a system compromise, which is what depth means. Set limits that match reality: memory_limit, upload_max_filesize, and max_execution_time sized to the application, not left at whatever the package shipped.
Databases get the same treatment: the application's DB account has table-level rights on its own schema, no GRANT, no SUPER, no access to other databases. A SQL injection with a least-privilege account reads one app's data; with root it owns the server's every schema.
What To Check
Before moving on, make sure you can:
- explain why write-access-to-code is the permission that matters most for PHP
- lay out ownership so deploys can write, the runtime can read, and uploads cannot execute
- apply and verify systemd sandboxing directives on a service
- name the production php.ini settings and what each prevents
- scope a database account to least privilege
What You Should Be Able To Do
After this lesson, you should be able to configure a PHP service stack where each process runs confined to its role, verify the confinement with tests rather than trust, and explain how each restriction shrinks the blast radius of a specific compromise.
Practice
Practice: Design Least-Privilege Layout
Design users, ownership, writable paths, systemd confinement, and database grants for a server running nginx, php-fpm, a queue worker, and MySQL for one application with user uploads.
Show solution
Filesystem: code readable by app-run, writable only by deploy. Writable paths for app-run are exactly storage/ and uploads/. nginx serves uploads/ with the PHP handler disabled for that location, so nothing writable is executable.
systemd: php-fpm and especially the queue worker get NoNewPrivileges, ProtectSystem=strict with ReadWritePaths naming storage and uploads, ProtectHome, PrivateTmp. The worker is first because it is long-running, custom, and least watched. systemd-analyze security scores are recorded as the baseline.
php.ini: expose_php Off, display_errors Off with logging on, disable_functions covering process-execution functions (the app does not shell out), limits sized to the app.
MySQL: the app account has CRUD on its own schema only; migrations run as a separate, more-privileged account used only by deploy tooling.
Verification is part of the design: a test asserts a PHP file written to uploads returns as text, not execution; a worker exploit simulation (touch a file outside allowed paths) fails; the app account's grants are dumped and diffed in CI.
Practice: Diagnose A Blast Radius
A file-upload vulnerability let an attacker place a webshell that ran with full write access to the codebase and a database account that could read every schema on the shared MySQL server, including two other applications.
Task
Identify each least-privilege failure that turned one bug into a multi-application compromise, and state the single most effective missing control.
Show solution
- The upload directory was executable: the web server ran PHP from a path the application can write. Writable-or-executable-never-both would have turned the webshell into an inert text file — this is the single most effective missing control, because it breaks the chain at step one.
- The runtime user could write to the codebase, letting the attacker persist beyond the upload directory and trojan legitimate files. Code owned by a deploy user, read-only to php-fpm, confines persistence to paths integrity monitoring watches hardest.
- The database account was wildly over-scoped: cross-schema read on a shared server converted one app's compromise into three. Per-app accounts with per-schema grants are the boundary that failed.
- Nothing confined the process: systemd sandboxing would not have stopped the webshell, but
ProtectSystem=strictwould have blocked codebase writes at a second layer, anddisable_functionswould have raised the cost of the shell-out the attacker used for reconnaissance.
The lesson generalizes: the vulnerability determines where an attacker starts; privileges determine where they stop. Response includes rebuilding the host and rotating every credential all three applications ever gave that server, per the compromise-response lesson.
Practice: Define Privilege Review Checks
Define the recurring checks that detect privilege drift across filesystem, service confinement, PHP configuration, and database grants.
Show solution
- A script asserts the ownership contract: no file under the codebase writable by the runtime user, writable paths exactly the named set, uploads non-executable (verified by requesting a planted
.txt.phpfixture and expecting text). systemd-analyze securityscores per unit diffed against baseline; a score worsening means someone edited a unit.- Effective php.ini values (
php -ion each pool) diffed against the committed hardening set —display_errorscreeping on in production is the classic drift. - Database grants dumped (
SHOW GRANTS) per application account and diffed against the committed expectation; any new privilege alerts.
Quarterly, human:
- Re-justify every entry in
ReadWritePaths, every writable directory, everydisable_functionsexception, every grant. Privilege reviews shrink scopes over time or they are theater. - Pick one service and attempt the confinement's promises from inside it (write outside allowed paths, escalate via setuid): a fire-drill for sandboxing.