Deploying a small tool in a single bash file
I have replaced my last three CI/CD pipelines with a 60-line bash script. It is uglier than any of them and I have not once wished for the pipeline back.
Every time I have set up a "proper" CI/CD pipeline for a small project — GitHub Actions, GitLab, Circle, name your poison — I have ended up spending more time on the pipeline than on the actual deployment target. And every time I have replaced the pipeline with a single bash file that runs from my laptop and sshes into the VPS, I have gotten my Saturdays back.
Here is the current version, annotated. It fits on a page. It has been running my last three projects for about a year. I do not love bash, exactly — I use bash — but for a one-person deployment onto one VPS, it turns out that "just run a shell script" is embarrassingly hard to beat.
#!/usr/bin/env bash
# deploy.sh — build local, rsync, restart. that is the whole trick.
set -euo pipefail
APP=csv-summary
[email protected]
REMOTE=/srv/$APP
LOCAL=./app
echo "==> checking git is clean"
[[ -z "$(git status --porcelain)" ]] || { echo "dirty tree"; exit 1; }
echo "==> running tests"
python -m pytest -x -q
echo "==> building assets"
python scripts/build_assets.py
echo "==> rsync"
rsync -avz --delete --exclude '.git' --exclude '__pycache__' --exclude '.venv' --exclude 'data/' "$LOCAL/" "$HOST:$REMOTE/"
echo "==> installing deps"
ssh "$HOST" "cd $REMOTE && ./venv/bin/pip install -r requirements.txt --quiet"
echo "==> migrating db"
ssh "$HOST" "cd $REMOTE && ./venv/bin/python -m app.migrate"
echo "==> restarting"
ssh "$HOST" "systemctl restart $APP"
echo "==> smoke test"
sleep 2
curl -fSs "https://shipping.example.com/health" | grep -q ok
echo "==> ok. deployed $(git rev-parse --short HEAD)"
What each step is doing, and why
set -euo pipefail. Fail loudly on the first mistake. Everything else in this file depends on this.
git clean check. If my working tree is dirty, I am about to deploy something I cannot reproduce from git. Refuse.
tests. If they don't pass here, they won't pass in production either. Fail loudly and fix the local machine first.
rsync. Not scp. Not git pull on the server. Rsync gives me an atomic-ish deploy (the file list is computed once) and it exclude-patterns junk I do not want on the server. The --delete flag is intentional: I want the remote to be an exact copy of my working tree, not a slowly-accreting midden of old files.
pip install --quiet. Only prints on error. Reduces the deploy log to something a human will actually read.
systemctl restart. One line. There is a unit file on the server that already knows how to run this app. Deploy does not have to.
curl health check. The single most important line in the script. Every previous incarnation of my deploy scripts, at some point, silently rebooted the app in a broken state and left it dark. Adding a two-second sleep and a hard-fail on a health endpoint has, more than any other change, made deploys uneventful.
The systemd unit, briefly
[Unit] Description=CSV summary tool After=network.target [Service] User=csv WorkingDirectory=/srv/csv-summary ExecStart=/srv/csv-summary/venv/bin/gunicorn -k uvicorn.workers.UvicornWorker -w 2 -b 127.0.0.1:8007 app.main:app Restart=always RestartSec=2 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target
That is the entire "runtime" configuration. Journaled logs, restart on crash, no orchestrator, no sidecar, no init system that I have to argue with. If I need a second app on this box I copy the unit, change the port and the working directory. Done.
Where this stops working
Two situations. First, when the deployment target is more than one machine. Rsync from a laptop to two servers is fine; from a laptop to twelve is ridiculous, and at that point you need a real pipeline that can update them in a controlled sequence. Second, when the team is more than one person. A shell script that has to live in one engineer's home directory does not scale to five engineers deploying at 4pm on a Friday.
Both of those thresholds are much higher than most side projects will ever reach. If your project is one of them, you have my sympathy, and also my congratulations.
Small things worth adding
- A
--dryflag that runs rsync with--dry-runand stops before the restart. Very useful for "am I about to deploy what I think I'm about to deploy." - Logging the deployed git sha into a small file on the server (
date + sha), sossh $HOST 'cat /srv/$APP/.deployed'answers "what's running right now?" without any observability platform. - A companion
restore.shthat pulls the latest litestream backup, provisions a fresh VPS with a cannedbootstrap.sh, restores the sqlite file, and points DNS at the new box. I have run this in anger exactly once. Fifteen minutes, from cold to serving traffic.
The point
Complexity is a cost. The right amount for one person on one VPS is: less than you think. Save the pipeline for the team you don't yet have.