It started, as these things always do, with a minor annoyance. Every time I deployed a new service to one of our VPS instances, I had to run the same dozen commands in roughly the same order. SSH in, pull the latest code, rebuild the container, check the logs, update the reverse proxy config, reload Nginx, test the endpoint. Rinse. Repeat. For every single service, on every single box.
The rational response would have been to write a quick shell script. Maybe 30 lines. Hardcode the paths, move on with my life. But I'm a developer, which means I have a constitutionally inability to solve a small problem in a small way.
The First Weekend: "This Will Be Quick"
I opened a new directory, ran npm init, and told myself this would be a simple Node.js CLI. A single binary. Maybe three commands: deploy, status, and rollback. I'd ship it by Sunday night.
The first thing I built was the config parser. Every project would have a small .deploy.yml file describing the service name, the target host, and the build command. Simple enough:
yaml
# .deploy.yml
service: api-gateway
host: prod-01.example.com
build: docker compose up -d --build
health: /api/health
timeout: 30
Then I needed to parse it. I reached for js-yaml, wrote a loader, added validation with zod, and before I knew it, I had a 200-line config module that could handle inheritance, environment overrides, and multi-service stacks. It was Saturday afternoon. I hadn't written a single line of deployment logic.
The gap between "I'll just write a quick script" and "I've built a framework" is about four hours of uninterrupted coding time.
Scope Creep Wears a Friendly Face
By the end of the first week, I had a working prototype. It could parse configs, SSH into a remote host, pull code, and run a build command. But it was ugly. The output was a wall of text with no structure. So I added chalk for colors. Then ora for spinners. Then a custom log formatter that aligned timestamps and padded service names so everything lined up neatly in the terminal.
The deployment logic itself was about 40 lines. The pretty-printing was 180.
Then a friend saw a screenshot and asked, "Can it do blue-green deployments?" And I thought: well, why not? That's just running the new container alongside the old one, switching the proxy, and tearing down the original. Three steps. Maybe a day of work.
It was not a day of work.
The SSH Problem
Blue-green deployments meant I needed reliable state tracking. Which container is "blue" and which is "green"? What happens if a deployment fails halfway through? How do I roll back cleanly?
I ended up building a small state machine that tracked each deployment through five phases:
- Prepare — validate config, check host connectivity
- Build — pull code, build the new container
- Stage — start the new container on a shadow port
- Switch — update the reverse proxy to point to the new container
- Cleanup — remove the old container and dangling images
Each phase could succeed, fail, or timeout. Failures in the first two phases would abort cleanly. Failures in the switch phase would attempt an automatic rollback. The whole thing was event-driven, with each phase emitting lifecycle hooks that the pretty-printer consumed.
javascript
const PHASES = ['prepare', 'build', 'stage', 'switch', 'cleanup'];
class Deployer extends EventEmitter {
constructor(config) {
super();
this.config = config;
this.phase = 0;
this.state = 'idle';
}
async run() {
for (const phase of PHASES) {
this.emit('phase:start', phase);
try {
await this[phase]();
this.emit('phase:done', phase);
} catch (err) {
this.emit('phase:fail', phase, err);
if (this.canRollback(phase)) {
await this.rollback();
}
throw err;
}
}
}
}
I was genuinely proud of this code. It was clean, it was testable, and it handled edge cases I'd never even encountered in production. The problem was that I'd now spent three weeks on a tool that was supposed to take a weekend. And I still hadn't deployed a single thing with it.
There's a particular kind of procrastination that feels exactly like productivity. You're writing code, solving real problems, making visible progress — and yet the thing you sat down to do remains undone. I've come to think of it as productive avoidance, and it's the most dangerous kind.
The Moment It Clicked
I was sitting on the porch one evening, watching my daughter ride her bike in circles in the driveway. She'd just learned to ride without training wheels and was still wobbly, still occasionally putting a foot down. But she was riding. Imperfectly, joyfully, completely unconcerned with optimization.
And I realized: my deployment script had been "riding" three weeks ago. It was wobbly. It had no spinners or state machines or blue-green anything. But it worked. It did the thing I needed it to do.