$ ship-small_
a3f27b1 · 2026-06-24 · 7 min

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

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.

Get the next log entry

One long-form devlog post most weeks, in your inbox. Plain text, no tracking, unsubscribe with one click. Never any launches.

free · plain text · no read tracking