The Boring Deploy Script
For a single-server PHP application, I deploy with a shell script - about 200 lines of bash I can read in one sitting. Not a hosted CI/CD pipeline, not a container orchestrator. People sometimes read that as "hasn't gotten around to setting up real CI." It's the opposite: a deliberate choice. This note is about why you'd choose the boring thing on purpose.
What the script actually does
The job is small and specific: get the right commit onto this one box, atomically, without two deploys colliding or a bad ref going live. So the script is mostly guardrails:
- Dirty-worktree rejection. If the live checkout has uncommitted changes, refuse to deploy. A deploy should never quietly bury a hotfix someone made on the box at 2 a.m.
flock-based serialization. Take an exclusive lock; if another deploy holds it, fail fast instead of interleaving two deploys into a corrupt state.- Ancestor-only ref enforcement. The target commit must be a fast-forward of what's deployed, so you can't accidentally ship a divergent or rewound branch. An explicit override flag covers the rare intentional case, because a guardrail you can't bypass on purpose is one people route around.
- Post-deploy healthcheck. After moving the code, hit the app and confirm it serves before declaring success.
- Rollback pointer. Record the previous commit and expose a one-command rollback. Recovery is "run this," not "figure out what was deployed."
- A structured deploy log. Every deploy and failure appends who, what, and when. When something's weird, the log is the first place you look.
Tests and static checks run separately, in hosted CI on push. The split is deliberate: CI answers "is this commit good?", the deploy script answers "get this good commit onto the box safely." Conflate them and you end up unable to deploy because a runner is down.
Why not "real" CI/CD?
Because every tool you add to the release path can fail in the release path. A hosted runner puts runner availability in your critical path, your deploy secrets with a third party, and a network round-trip in front of a one-server deploy. For a fleet, blue/green, or a team where many people ship, those costs buy real things - parallelism, a marketplace of actions, audited multi-environment promotion. For one app on one server, they buy mostly overhead.
The honest trade-off:
- You give up: matrix builds, parallelism, the actions ecosystem, multi-environment promotion, and the social proof of a recognizable pipeline.
- You gain: zero external dependencies in the release path, a pipeline you can fully read and reason about, and guardrails tailored to exactly your failure modes instead of a tool's defaults.
When I'd change my mind
This is what makes it an engineering decision rather than a preference. I'd reach for a managed pipeline the moment any of these became true:
- More than one server, or a need for blue/green or canary releases.
- A team where several people deploy and the shared mental model frays.
- A compliance need for audited, gated promotion across environments.
None of those are true here, so the boring script wins. The point isn't that shell scripts beat CI/CD. It's that "what does this system need?" beats "what does everyone use?" - and being able to say when you'd switch is what separates a chosen default from a cargo-culted one.
The actual lesson
The value was never the script. It's the habit of writing down what you give up, what you gain, and the conditions under which you'd decide differently. Do that for your deploy path and you'll do it for your database, your queue, and your architecture - which is most of the job.
The decision-and-tradeoffs version of this is recorded as ADR 0004, a guarded shell deploy over a hosted CI runner.