Every developer knows they should have CI/CD. Fewer actually do — mostly because the examples out there assume you have a platform team, a Kubernetes cluster, and a budget for GitHub Actions minutes.
I didn’t have any of that. Here’s what I built instead.
The reality of small-team CI/CD
When you’re a solo developer or a small team, “CI/CD” usually means one of three things:
- You push to
mainand hope nothing breaks - You have a deploy script that you run manually and sometimes forget steps on
- You have a full GitHub Actions pipeline that takes 12 minutes and costs more than your hosting
None of these are ideal. The first two are fragile. The third is overkill.
What I actually needed
My setup is simple: a monorepo with a few apps, deployed to a single VM via Docker Compose. I needed:
- On push to
main: lint, build, and deploy automatically - On pull request: lint and build only (no deploy)
- Rollback: if something breaks, I can revert and redeploy in seconds
- Visibility: I want to know what failed and why, without digging through logs
The pipeline
# Simplified GitHub Actions workflow
name: Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm build
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/foxxxesky-apps
git pull origin main
docker compose up -d --build
That’s it. Two jobs: build and deploy. The build job runs on every push. The deploy job only runs on main and SSHs into the server to pull and rebuild.
Why this works for small teams
No infrastructure to maintain. GitHub Actions runs the CI. SSH runs the CD. No self-hosted runners, no Jenkins server, no ArgoCD.
Fast feedback. The build job takes about 2 minutes. If lint fails, you know before merge. If build fails, you know before deploy.
Rollback is just git revert. Since deploys are triggered by git pushes, reverting a commit and pushing triggers a new deploy. No special rollback commands, no database migrations to undo (hopefully).
Cheap. GitHub Actions gives you 2,000 minutes free per month on public repos. For a small team, that’s more than enough.
The parts I skipped
Staging environments. I deploy directly to production. For my scale, the risk is low and the overhead of maintaining a staging environment isn’t worth it. If I were shipping to paying customers, I’d add staging.
Automated testing in CI. I don’t have a test suite yet. When I do, adding pnpm test to the build job is a one-line change. For now, lint and build catches most issues.
Database migrations in CI. Migrations run manually on the server. Automating this requires careful handling of migration state and rollback logic — not worth the complexity for a single-database setup.
When it doesn’t fit
This approach works for:
- Solo developers and small teams
- Simple deployment targets (single VM, Docker Compose)
- Projects where downtime during deploy is acceptable (seconds, not minutes)
It doesn’t work for:
- Multi-region deployments
- Zero-downtime requirements
- Complex microservice architectures
- Teams that need approval gates and audit trails
For those, you need proper CI/CD tooling — but that’s a different article.
The lesson
CI/CD doesn’t have to be complex. Start with the simplest thing that works: lint, build, deploy. Add complexity only when you have a specific problem to solve. The best pipeline is the one you actually use.