Closing the Obvious Attack Paths Before DNS Points at You
The suite from Covering the API with PHPUnit proves the API does what the acceptance list promised. It says nothing about what happens when the traffic is hostile — a different genre of problem with its own checklist, and every item on it is cheap now and expensive once the hostname is public.
Layer 1 — the PHP engine. Server-level ini, not source-code ini_set:
; /etc/php/8.3/fpm/php.ini (production values)
display_errors = Off ; errors go to the log, never to the client
log_errors = On
expose_php = Off ; drops the X-Powered-By: PHP/8.3.x fingerprinting header
memory_limit = 256M
max_execution_time = 10
display_errors must be off at the pool level because ini_set never executes for a parse error or an exception thrown before bootstrap — the exact moment a stack trace would leak APP_ENV, DSNs, and file paths.
Layer 2 — security headers, added in nginx so they apply even when PHP fails:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Access-Control-Allow-Origin "https://app.example.com" always; # never *
A pure JSON API needs a default-src 'none'-grade CSP — it buys you a hard guarantee that a reflected string can never execute in a browser that renders your response as a document. The Access-Control-Allow-Origin value is a decision, not a wildcard: browsers enforce it, and * plus Access-Control-Allow-Credentials is the pair that turns any website into a credential-stealing client for your authenticated users.
Layer 3 — rate limiting, at the edge, before a flood costs FPM workers and DB connections:
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
}
server {
location / {
limit_req zone=api burst=40 nodelay;
try_files $uri /index.php?$query_string;
}
}
429 Too Many Requests on abuse, transparent for the burst of page loads a human produces. burst=40 nodelay lets a dashboard load fire its parallel requests immediately and still enforces the sustained average — a human never notices, while a script looping credential guesses earns 429 within seconds. Log limiter rejections (limit_req_log_level warn;) so abuse surfaces in the same place as the application's own errors. Per-IP limiting is the outer layer; per-user token buckets in the application (keyed by the authenticated identity, so one noisy tenant cannot exhaust the shared IP budget) are the inner one.
Layer 4 — secrets out of the repository. The pattern:
// config/AppConfig.php — committed file, no values
return [
'db' => [
'dsn' => getenv('DB_DSN'),
'user' => getenv('DB_USER'),
'pass' => getenv('DB_PASS'),
],
];
# /etc/inventory/app.env (root:root 600, NOT under the web root)
DB_DSN=mysql:host=127.0.0.1;dbname=inventory;charset=utf8mb4
DB_USER=inventory_app
DB_PASS=...
# /etc/systemd/system/inventory.service
[Service]
EnvironmentFile=/etc/inventory/app.env
.env files committed to git are the most common credential leak in PHP projects — and if a secret ever was committed, moving it is insufficient: rotate it, because git history retains every version. The .env composer package that loads a file from the project root is convenient locally; in production, systemd-injected variables and a file outside the document root remove the "downloadable via a mis-routed request" scenario entirely.
Finally, the inputs: validation and auth are already enforced in the handlers, and that is deliberate — edge defenses (nginx client_max_body_size 1m;, the rate limiter) reduce load, while the application remains the only layer that actually validates, because the edge cannot see your field rules. The checklist closes the obvious doors; the point of running it before launch is that every item above is a two-line config change today and an incident review later.
With the attack surface closed, the server itself needs building: nginx, php-fpm, and the systemd units that keep them alive — the next chapter.
nginx, php-fpm, and systemd: The Server Build, Step by Step
Hardening assumed a running stack. Here is the stack, built from an empty Ubuntu 24.04 box, in the order you would actually type it:
sudo apt update
sudo apt install -y nginx php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-curl php8.3-intl
php -v # 8.3.x — the FPM package and the CLI you test with must agree
sudo systemctl enable --now php8.3-fpm nginx
ls /run/php/php8.3-fpm.sock # the socket nginx will hand requests to
Three parts of that list deserve the why. php-fpm is a separate process pool from the web server — nginx speaks HTTP to the client and FastCGI to the pool over a Unix socket, so a worker stuck on a slow query does not block nginx's event loop. php8.3-mbstring and friends are separate packages: an API calling mb_substr on a box missing the module dies with a 500 at the first non-ASCII product name. And the CLI version is what runs your migrations, so php -v and the FPM version being different is a bug you find at deploy time, not before.
The vhost — the piece that turns a directory of PHP files into an API:
# /etc/nginx/sites-available/inventory
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
client_max_body_size 1m;
root /var/www/inventory/current/public;
index index.php;
location / {
try_files $uri /index.php?$query_string; # every unknown path → front controller
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~* \.(css|js|png|jpg)$ { # static assets: served directly, cached
expires 30d;
add_header Cache-Control "public";
}
}
try_files $uri /index.php?$query_string is the front-controller rewrite from the API topic made real: /products/A1 reaches index.php, which routes it. Note what is not there — no location ~ \.php$ serving arbitrary files from the project root, because root points at public/ only. Allowing execution anywhere else is how a writable uploads/ directory becomes remote code execution.
Before reloading, always verify and enable:
sudo nginx -t # syntax + referenced files; fix before touching the running server
sudo ln -s /etc/nginx/sites-available/inventory /etc/nginx/sites-enabled/
sudo systemctl reload nginx # reload, not restart — zero dropped connections
curl -fsS https://api.example.com/healthz && echo OK # the health check that gates deploys
Permissions follow the process model: the FPM pool runs as www-data, and the web user must never own the application code. A deploy step writes as your deploy user; www-data reads it:
sudo chown -R deploy:deploy /var/www/inventory
sudo chown -R www-data:www-data /var/www/inventory/storage # logs & cache: the only writable dirs
If www-data can write index.php, then a single file-upload bug is a full compromise; if it owns only storage/, the worst case is a trashed log directory.
Finally the release itself, run by the deploy script:
rsync -a --delete --exclude .env ./ deploy@server:/var/www/inventory/releases/2026-03-14/
ssh deploy@server 'cd /var/www/inventory/releases/2026-03-14 \
&& composer install --no-dev --optimize-autoloader --no-interaction \
&& php bin/migrate.php status'
--no-dev keeps PHPUnit and dev tooling out of production (smaller attack surface, fewer autoload surprises), --optimize-autoloader builds the classmap so autoloading is an array lookup rather than thousands of file_exists calls per request, and --no-interaction stops a deploy from hanging on a prompt nobody is watching. The migration step prints status before applying — reviewing the pending set is the last chance to notice an incompatible change.
None of that matters if you cannot get back to the previous working state. The next chapter makes that boring.
Making Rollback a Boring, Documented Command
A deploy that cannot be undone is not a deploy — it is a bet. The layout that makes reversal cheap is the symlinked release directory, and every command below has one job: make "put it back" take seconds instead of a standup.
/var/www/inventory/
├── releases/
│ ├── 2026-03-13/ # previous release, still complete
│ │ ├── public/ index.php src/ bin/ storage/
│ │ └── vendor/ # its own composer install
│ └── 2026-03-14/ # the release under test
├── current -> releases/2026-03-14
└── shared/
├── .env # systemd EnvironmentFile points here
└── storage/ # logs, cache — symlinked into every release
current is the only thing nginx's root references, and flipping it is atomic because you create a temporary link and mv it over the old one — a plain ln -sfn is already near-atomic, but the mv variant guarantees no request ever resolves a half-written link:
ln -sfn /var/www/inventory/releases/2026-03-14 /var/www/inventory/current.tmp
mv -Tf /var/www/inventory/current.tmp /var/www/inventory/current
systemctl reload php8.3-fpm # workers pick up the new code paths
curl -fsS https://api.example.com/healthz # gate: must return 200 before you walk away
The health endpoint is deliberately trivial — it touches config load and one SELECT 1 — because its purpose is to catch boot-level breakage: a missing env var, a bad DSN, a fatal during bootstrap. Anything deeper is caught by the post-deploy smoke check (one GET /products from outside the box).
Rollback is then the same three lines with an older target:
# RUNBOOK: rollback (target: < 5 minutes, owner: on-call)
ln -sfn /var/www/inventory/releases/2026-03-13 /var/www/inventory/current.tmp
mv -Tf /var/www/inventory/current.tmp /var/www/inventory/current
systemctl reload php8.3-fpm
curl -fsS https://api.example.com/healthz && echo "rolled back to 2026-03-13"
Written down, with the previous release path named. In an incident nobody reconstructs shell history — the runbook exists so the engineer who is not the author can execute it at 2 a.m. Practice it once on a quiet afternoon: a rollback you have never run has never been proven, and the first time you discover that mv -Tf behaves differently on some filesystem is not the time you want to learn it.
The database is the one part a symlink cannot roll back, which is why schema changes follow expand-contract: a deploy may add a column/table (expand) that old code ignores; removing or renaming anything the previous release still uses (contract) waits for the release after the one that stopped using it. Two releases of overlap, and code rollback never touches the database. When a schema change genuinely must reverse, the down-migration runs — and only then:
php bin/migrate.php status # what is applied, what is pending
php bin/migrate.php down --to=0004 # only if the migration itself is the failure
Before any migration that could lose data, mysqldump is a prerequisite of up, not a component of the rollback plan — restoring a dump rolls back writes that arrived after the backup, so it is the last resort, not the first reflex.
Keep only the last few releases on disk (releases/ pruned to N=5 by the deploy script) so rollback targets always exist without the disk filling. And prune after the health check passes, never before — the moment you delete the previous release is the moment rollback stops being an option.
That is the capstone complete: resources scoped, schema enforced, endpoints consistent, tests guarding behavior, and a release you can undo. The remaining work is yours — build it, break it on purpose, and roll it back once on a quiet afternoon, because the first time you execute a rollback should not be during an incident.
Quiz
1. The capstone app keeps its MySQL password in config/config.php, committed to the repository. What is the correct fix?
2. Where must PHP-FPM be told to not display errors and not expose the engine version in production?
3. What does the nginx limit_req zone actually protect against, and where does it belong?
4. You deployed release 2026-03-14 and the health check fails. What does a correct rollback procedure do?
Flashcards
Question
The hardening checklist before a PHP API faces the internet
Click to reveal answer
Answer
display_errors=Off + logged errors, expose_php=Off, security headers (Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options/frame-ancestors), CORS locked to known origins, rate limiting via nginx limit_req plus per-user buckets, client_max_body_size capped, secrets from environment, TLS only.
Question
The nginx → php-fpm request path for the front controller
Click to reveal answer
Answer
location / { try_files $uri /index.php?$query_string; } routes every path to index.php; location ~ \.php$ passes to the unix socket via fastcgi_pass with SCRIPT_FILENAME set to $document_root$fastcgi_script_name; static assets are served directly with far-future Cache-Control. Files under public/ only — the app root is never web-accessible.
Question
How secrets and environment config are kept out of the repository
Click to reveal answer
Answer
Placeholders in committed config, real values injected at runtime: Environment= lines in the systemd unit, an env file owned by root outside the web root, or a secrets manager. PHP reads getenv('DB_PASSWORD'). A credential that was ever committed is rotated, not just moved — history retains it.
Question
The release layout that makes deploys and rollbacks cheap
Click to reveal answer
Answer
releases/2026-03-14/ each with vendor/ from `composer install --no-dev --optimize-autoloader`, plus a `current` symlink flipped atomically (ln -sfn + rename). shared/ holds .env and uploads; releases symlink to it. Rollback = relink to the previous release + `systemctl reload php8.3-fpm` + health check.
Question
What runs in what order on deploy — and what makes it safe to re-run
Click to reveal answer
Answer
Pull/build artifacts → `composer install --no-dev --optimize-autoloader` → `php bin/migrate.php status` (pending set reviewed) → `up` → flip symlink → reload php-fpm → health check `curl -fsS /healthz`. The schema_migrations tracker makes the migration step idempotent, and health-check failure triggers the documented rollback.
Revision Notes
Key Takeaways
- 1. Production hardening is layered: PHP ini (display_errors off, expose_php off), nginx (headers, limit_req, body-size cap), app (validation, auth) — each catches what the others cannot
- 2. Secrets come from the environment or root-owned files outside the web root; committed credentials are rotated because git history never forgets
- 3. nginx routes every path to public/index.php via try_files + fastcgi_pass on a socket; only public/ is web-accessible and static assets get cache headers
- 4. Deploys are release directories plus an atomically flipped `current` symlink, with composer --no-dev --optimize-autoloader and migrations from the tracked runner
- 5. Rollback is a documented pointer flip plus php-fpm reload plus health check, with a stated time budget; down-migrations and database restores are last resorts
Interview Tips
- • "How do you deploy a PHP app?" — walk the chain: build artifacts with --no-dev --optimize-autoloader, run pending migrations from the tracked runner, flip the symlink, reload php-fpm, health check; the order is the answer
- • "Where do secrets live in production?" — environment injected by the systemd unit or a root-owned file outside the web root; add that committed secrets get rotated because history retains them
- • "Explain your rollback plan" — symlink back to the previous release, reload, verify health, within a stated budget; then note schema changes are deployed backward-compatibly so most rollbacks never touch the database
- • "How would you rate-limit this API?" — layered: nginx limit_req per IP at the edge, per-user token bucket in the app keyed by auth identity, plus capped page size and body size; name which layer sheds load first
Cheat Sheet
Hardening and Deploying the Release
Hardening layers:
| Layer | Setting | Why there |
|---|---|---|
FPM php.ini |
display_errors=Off, log_errors=On, expose_php=Off, max_execution_time=10 |
ini_set never runs for parse/fatal errors |
| nginx | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, strict CSP default-src 'none', HSTS, CORS to known origins only |
headers apply even when PHP 500s |
| nginx | limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s + burst=40 nodelay; client_max_body_size 1m |
sheds flood traffic before FPM workers/DB connections |
| app | validation + auth in handlers | the only layer that sees field rules |
| secrets | getenv('DB_PASS') ← systemd EnvironmentFile=/etc/inventory/app.env (root:root 600, outside web root) |
committed secrets are rotated — git history retains them |
Server build: apt install nginx php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-curl php8.3-intl → systemctl enable --now php8.3-fpm nginx. Vhost: root .../current/public, try_files $uri /index.php?$query_string, fastcgi_pass unix:/run/php/php8.3-fpm.sock with SCRIPT_FILENAME $document_root$fastcgi_script_name. Verify nginx -t → enable → reload (not restart) → curl -fsS /healthz. Web user reads code (chown deploy:deploy), owns only storage/.
Release + deploy:
rsync -a --delete --exclude .env ./ deploy@server:/var/www/inventory/releases/2026-03-14/
cd releases/2026-03-14 && composer install --no-dev --optimize-autoloader --no-interaction
php bin/migrate.php status # review pending set → up
ln -sfn .../releases/2026-03-14 current.tmp && mv -Tf current.tmp current
systemctl reload php8.3-fpm && curl -fsS /healthz # gate
Rollback runbook (< 5 min): relink current to the previous release → reload php-fpm → health check. Schema: expand-contract (overlap two releases before dropping/renaming anything old code uses); migrate down --to=NNNN only when the migration itself broke; mysqldump before destructive up, never as the rollback plan. Prune releases/ to 5 — after a successful health check.