
We’ve fixed the queries and the browser. This time: the moment everything stops being under your control — the deploy. “It works on my machine” is not a bug report, it’s a missing category of test. Here are the five failure modes I see in deployments, in order of how much they hurt.
1. The environment drift
Your laptop has PHP 8.3, the server has PHP 8.1, and the difference is one deprecated function that only fires on a rare code path. The fix isn’t “update the server.” It’s pinning the runtime in a file that travels with the code:
<?php
// composer.json
{
"config": {
"platform": { "php": "8.1.28" }
},
"require": { "php": ">=8.1" }
}
Now composer install on any machine resolves against the exact version the server runs. Drift becomes a loud, early error instead of a 3am incident.
2. Secrets in the wrong place
API keys in a config file that gets committed is the classic. The pattern I use:
.env.examplein git — the names of every variable, with placeholder values..envon the server only — the values, never in git, never in a deploy artifact.- A deploy script that fails fast if a required variable is missing:
REQUIRED="DB_DSN DB_USER DB_PASS APP_KEY"
for v in $REQUIRED; do
[ -n "${!v}" ] || { echo "MISSING: $v"; exit 1; }
done
A deploy that fails in two seconds beats a deploy that fails in production.
3. The “last commit” assumption
Deploying main is fine only if main is a build you’ve actually seen pass. Two rules that prevent most bad deploys:
- Tag what you ship.
git tag -a v1.4.2 -m "release"— the tag, not the branch head, is the unit of deploy. You can always point atv1.4.1later. - Ship from the tag’s SHA, not the branch.
git checkout v1.4.2gives you a stable ref even as the branch moves on.
This one habit is the difference between “roll back to what we shipped” and “dig through deploy logs to reconstruct what we shipped.”
4. Migrations that can’t roll back
A schema change deployed alongside the code that needs it is a coin flip: if the code fails health checks and you roll back the deploy, the old code now hits a new schema. The fix is expand → migrate → contract:
- Expand: add the new column, make it nullable. Deploy code that reads old OR new. Both versions coexist.
- Migrate: backfill the new column from the old one. Old code still works (it reads the old column).
- Contract: once every reader is off the old column, drop it. This is the only step that needs a coordinated deploy — and it’s now a pure drop, not a data migration.
Rollback at any point is a deploy revert, not a data surgery.
5. No health check, no canary
The last line of defense: a deploy that proves the thing works before it flips traffic. Minimum viable version:
# health.sh — runs post-deploy, exit non-zero on failure
curl -fsS -m 5 "https://example.com/healthz" | grep -q '"ok":true'
php -r 'exit((int)(!is_readable("public_html/cache/app.php")));'
echo "health OK"
# in your deploy script, after rsync
./health.sh || { echo "HEALTH FAILED — rolling back"; ./rollback.sh; exit 1; }
./switch-traffic.sh
If you have one domain and no load balancer, the “canary” is a feature flag on the risky change, not a fleet of machines. Ship it dark, watch logs, flip it on. Same protection, zero infra.
The fifteen-minute deploy checklist
composer.jsonpins the PHP platform. ✓.env.examplelists every secret; deploy fails if any is missing. ✓- You deploy a tag, and the tag’s SHA is in the release notes. ✓
- Schema changes follow expand → migrate → contract. ✓
- Post-deploy health check runs; failure triggers rollback before traffic flips. ✓
None of this is glamorous. All of it is the difference between a deploy you remember because it was smooth, and one you remember because of the Slack thread. Next up: the part nobody budgets for — keeping the thing healthy after it ships.
— Stella