Deploying With Nothing But Git Push (Finally)
I spent most of last weekend redoing the deploy setup for one of my side projects, and I keep doing this to myself every couple of years. New project, new "this time I'll do it properly" energy, and then eighteen months later I'm back here again trying to remember how I wired the last one together. So this is partly a how-to and partly me writing it down so future-me stops re-learning it.
The project in question is a dumb little habit tracker I built for myself back in February. Nothing fancy, a Node app with SQLite, maybe 40 users if I'm being generous (most of them are me on different devices). It had been living on my old droplet next to three other apps, all sharing one nginx config that I was terrified to touch. Every deploy was: ssh in, git pull, npm install, restart the pm2 process, pray. It worked, technically, the same way a car with no working turn signals technically works.
What I actually wanted
Just git push production main and have the thing update itself. That's it. That's the whole ask. It's 2026, this should not be hard, and mostly it isn't once you stop overthinking it.
The classic version of this, if you want to build it yourself, is a bare git repo on the server with a post-receive hook:
git init --bare ~/apps/habits.git
Then in ~/apps/habits.git/hooks/post-receive:
#!/bin/bash
GIT_WORK_TREE=/home/deploy/habits git checkout -f main
cd /home/deploy/habits && npm install && pm2 restart habits
Make it executable, add the remote on your laptop (git remote add prod deploy@yourserver:apps/habits.git), and git push prod main actually does something now. First time it worked for me I sat there kind of stunned, like I'd finally beaten the boss level I'd been stuck on for years. It's such an old trick, too, this pattern has been floating around dev blogs since before I started this one back in 2011.
The rough edges show up fast though. Zero-downtime restarts need real work (pm2's reload helps but isn't magic). Environment variables live in a .env file you have to remember exists and never gets committed, which is exactly the kind of thing I forget at 11pm and then spend forty minutes debugging a "why is the database URL undefined" error that's just me being an idiot. And the second you have more than one app on the box you're back to juggling nginx configs and cert renewals by hand, which, no thank you.
Where I landed
Honestly, for this particular project, I gave up on hand-rolling it and just pushed it through Tricknowtech's git-push deploy setup instead — same git push workflow, but it handles the TLS cert and the process restart and doesn't ask me to remember which port 40 is running on. Took about ten minutes to point the domain at it and get the first push through. I still like knowing how the bare-repo version works underneath (feels important, the way knowing how to change a tire feels important even if you've got roadside assistance), but I'm not precious about running my own infra for a project with 40 users and no revenue. Life's too short.
One complaint while I'm here: SQLite plus any deploy pipeline that spins up a fresh container per push is a bad combination if you're not careful about where the database file actually lives. I lost a week of my own habit data to this exact mistake in March, mounted the volume wrong, container came up clean and my streak was gone. Very on-brand for an app about consistency to eat my own consistency data.
If you're doing this yourself and want the quick version: bare repo plus post-receive hook is genuinely fine for a single low-traffic app and it'll teach you more than any managed thing will. But the moment you're running more than one app, or you actually care about uptime, stop being stubborn about it sooner than I did. I wasted way more Saturday afternoons on this than the lesson was worth.
Next up I want to figure out proper zero-downtime restarts without dragging in something like Kubernetes for a hobby project that peaks at maybe six concurrent users, one of whom is my mom. If anyone's got a lightweight answer for that, my email's on the about page, always has been.