How I Use Git as a Deployment System (And Why It Works Perfectly for Solo Projects)
TL;DR / Key Takeaways
- A git push to main triggering a GitHub Actions SSH deploy is all you need for solo projects running on a single VPS.
- Simple systems fail simply. You can read the error, fix the error, and move on. Complex systems fail in ways you spend three days diagnosing.
- Rollback is one git command. Tags give you clean restore points before every meaningful change.
- This is the exact workflow I use to deploy Predict & Profit's Weather Bot and Econ Bot in production.
I have worked in environments where deploying a config change required opening a Jira ticket, waiting for a pipeline to queue, watching a Kubernetes rollout for twenty minutes, and then asking someone in DevOps why the pod was stuck in CrashLoopBackOff. I have been that person at 11pm on a Tuesday.
I do not do that anymore.
The Predict & Profit bots run on a RackNerd VPS. They deploy via git. The whole system is about forty lines of shell script and a GitHub Actions YAML file I wrote once and have barely touched since.
Here is how it works and why I think it is the right choice for anyone running a solo Python project in production.
The Core Philosophy
Simple systems fail simply.
When something breaks in a Kubernetes cluster, you are reading through layers of abstraction to find the actual error. When something breaks in a git pull and systemctl restart workflow, the error is right there in the terminal. It says exactly what failed. You fix it and move on.
I am not saying Docker is bad. I am saying Docker is a solution to problems I do not have. I have one server. I have two bots. I have a virtual environment and a systemd service file. That is the whole stack.
The goal is not to impress anyone. The goal is for the bots to run reliably and for me to be able to fix things fast when they break.
The Workflow
Every deploy follows the same path:
- Push to
mainon GitHub - GitHub Actions picks it up
- Actions SSH into the VPS
- Pull the latest code
- Rebuild the virtual environment if requirements changed
- Restart the systemd service
That is it. No build servers. No container registries. No helm charts.
Here is the GitHub Actions file:
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/predictandprofit
git pull origin main
source venv/bin/activate
pip install -r requirements.txt --quiet
sudo systemctl restart weatherbot
sudo systemctl restart econbot
echo "Deploy complete: $(date)"
The SSH key lives in GitHub Secrets. The VPS user has a sudoers entry that allows systemctl restart for those two services without a password prompt. Nothing else.
Branch Strategy
I keep it simple. Three branch types:
main is production. Whatever is on main is running on the server. I do not push to main unless I am ready to deploy.
dev is where I build. I run the bot in dry-run mode locally or against the staging environment before anything touches main.
Feature branches are named after what they do: fix/xarray-truthiness-bug, feature/early-exit, fix/positions-dict-key. Short lived. Merged and deleted.
# Start a new fix
git checkout -b fix/something-broke
# ... make changes, test locally ...
git add -p
git commit -m "fix: correct the thing that broke"
git checkout dev
git merge fix/something-broke
git branch -d fix/something-broke
# When dev is stable and tested
git checkout main
git merge dev
git push origin main
# GitHub Actions fires here
The -p flag on git add is something I started doing years ago and never stopped. It stages changes hunk by hunk. Forces you to read what you are actually committing instead of git add . and hoping for the best.
Tagging Before Anything Risky
Before I ship a significant change, I tag the current state of main.
git tag -a v2.1.0 -m "Weather Bot grand ensemble upgrade, 164 members, AIGEFS integration"
git push origin v2.1.0
Tags are cheap. They cost nothing. And when something breaks after a big upgrade, being able to say "I need to get back to exactly v2.0.3" is worth every second it takes to create them.
I tag before: major feature additions, dependency upgrades, any change to the trade execution logic, and anything that touches how the bot reads or writes to the database.
Rollback
This is where the "simple systems fail simply" thing pays off.
Something goes wrong after a deploy. The bot is throwing errors. I need to get back to the last known good state immediately.
# On the VPS, in the project directory
git log --oneline -10
# Find the commit or tag you want
git checkout v2.0.3
sudo systemctl restart weatherbot
That is it. The service is back on old code in about ten seconds.
If I tagged properly, I am rolling back to a named point I created intentionally. If I did not tag and I am hunting through commits, I have only myself to blame.
One thing to be aware of: git checkout on a tag puts you in detached HEAD state. That is fine for running production code. It is not fine for making new commits from that state. If I need to make a hotfix from a rollback point, I do this instead:
git checkout -b hotfix/emergency-fix v2.0.3
# make the fix
git commit -m "hotfix: patch the thing that blew up"
git checkout main
git merge hotfix/emergency-fix
git push origin main
Now Actions deploys the hotfix directly to prod.
What Actually Breaks
Honest accounting of where this workflow has failed me:
The pip install -r requirements.txt step occasionally installs a package version that conflicts with something else. The service restart fails. The fix is to log into the VPS, activate the venv, read the pip error, pin the version, push a fix commit.
ssh user@vps_host
cd /opt/predictandprofit
source venv/bin/activate
pip install -r requirements.txt
# Read the actual error
The systemd service sometimes fails to restart because the bot process did not exit cleanly. An old lock file, an open database connection, a signal that did not propagate. I handle this with a TimeoutStopSec in the service file and a KillMode=mixed setting. But occasionally I still have to SSH in and kill the process manually before the restart succeeds.
The GitHub Actions SSH step will silently succeed even if the script exits with an error, depending on how you configure it. I learned this the hard way. Add set -e to the top of your deploy script so any failing command stops the whole thing and marks the action as failed.
script: |
set -e
cd /opt/predictandprofit
git pull origin main
...
Without set -e, you can have a deploy where git pull fails, the script continues, and you restart a service on stale code. Everything looks green in Actions. Nothing actually updated. That is a fun one to debug.
The Requirements File Discipline
One thing that makes this workflow reliable is keeping requirements.txt honest. I use pip freeze > requirements.txt only after I have tested the current environment. And I pin versions.
requests==2.31.0
xarray==2024.2.0
cfgrib==0.9.12.0
anthropic==0.25.0
Unpinned requirements are a time bomb. Some transitive dependency releases a breaking version, your next deploy pulls it in, and now you are debugging an import error at 2am instead of sleeping.
If you want to see what is actually installed in your venv:
source venv/bin/activate
pip list --format=freeze | sort
Compare that against your requirements.txt periodically. They should match.
Why Not Docker
I know the counterargument. Docker gives you reproducible environments. It eliminates "works on my machine." It makes scaling easier.
All of that is true. For teams. For services that need to scale horizontally. For environments where multiple developers are working on the same codebase and need identical local setups.
I am one person. I have one server. My "local environment" is a virtualenv on my laptop that matches the virtualenv on the VPS because I maintain a single requirements.txt.
Docker would add: a Dockerfile to maintain, image builds on every deploy, a container registry to push to or a build step on the VPS, and a new failure mode every time the base image updates. For what? So I can say I use Docker?
The bots have been running in production for months on this git-based workflow. Zero deploy-related outages.
The Audit Trail You Get for Free
One underrated benefit of this workflow: git is your deployment history.
git log --oneline main
Every commit to main is a production deploy. The message tells me what changed. The timestamp tells me when. If something broke, I know exactly which deploy introduced it.
That is better than most enterprise deployment logs I have seen, and it costs nothing.
This workflow is not glamorous. It will not get you a conference talk. Nobody is going to write a case study about it.
But it runs. It recovers fast when it breaks. And when I am checking bot logs at 6am before my day job starts, I am not fighting my deployment system to get there.
Simple is underrated.