The Problem with Most CI/CD Tutorials
Most tutorials show you a pipeline that deploys a "hello world" app to a free Heroku instance. They skip the messy parts: secrets, rollbacks, and the moment your pipeline breaks because a dependency changed.
I've been there. After years of fighting with over-engineered setups, I settled on a minimal pipeline that's easy to understand, debug, and extend. It's not fancy, but it works.
The Core Idea
A CI/CD pipeline is just three stages:
- Test - run automated checks
- Build - create an artifact
- Deploy - push the artifact to a server
We'll use GitHub Actions because it's free for public repos and integrates with everything. But the same concepts apply to GitLab CI, CircleCI, or Jenkins.
The Pipeline File
Here's the complete .github/workflows/deploy.yml:
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
build-and-deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Deploy to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "dist/*"
target: "/var/www/myapp"
That's it. Let's break it down.
Stage 1: Test
The test job runs on every push and pull request. It checks out the code, installs dependencies with npm ci (which respects the lockfile), and runs your test suite.
If a PR fails tests, the build-and-deploy job won't run because of the needs: test dependency.
Stage 2: Build
The build-and-deploy job only runs on pushes to main (not on PRs). It builds your app into a dist folder.
For a Node.js app, npm run build might be a bundler like Vite or webpack. For a Python app, you'd replace with python -m build or similar.
Stage 3: Deploy
The deployment step uses scp to copy the built files to your server. It's dead simple and works for static sites, Node apps, or anything that runs behind a reverse proxy.
Secrets Management
Never hardcode credentials. In GitHub, go to Settings > Secrets and Variables > Actions, and add:
-
SERVER_HOST- your server IP or domain -
SERVER_USER- SSH username (usuallydeployorubuntu) -
SSH_PRIVATE_KEY- the private key for a dedicated deploy user
Create a separate user on your server with limited permissions. Don't use root.
Rolling Back
Every deployment overwrites the previous dist folder. That's fine for small apps, but if you break something, you need a quick rollback.
I keep the last three releases on the server:
# On server, before deploying
tar -czf /var/www/backups/$(date +%Y%m%d%H%M%S).tar.gz /var/www/myapp
Then to rollback, just extract the backup. You can automate this with a script, but even manual is better than nothing.
Handling Dependencies
npm ci installs exact versions from the lockfile. This prevents the "works on my machine" problem. For Python, use pip install -r requirements.txt with pinned versions.
Testing the Pipeline
Before you commit, test locally:
npm test
npm run build
If both pass, commit and push to main. Watch the Actions tab to see the pipeline run.
Common Pitfalls
-
SSH key permissions: Make sure the private key is in OpenSSH format, not PuTTY. If you generate with
ssh-keygen -t ed25519, it works. -
Path issues: The
source: "dist/*"assumes your build outputs todist. Adjust for your project. -
Server path permissions: The deploy user needs write access to
/var/www/myapp. Set it up once withsudo chown -R deploy:deploy /var/www/myapp.
Extending the Pipeline
Once this works, you can add:
-
Linting - add a
lintjob before tests - Database migrations - run a separate job after deploy
-
Notifications - send a Slack message on failure using
actions/slack-notify
But don't add them until you need them. The beauty of this pipeline is its simplicity. When something breaks, you can trace the entire flow in five minutes.
Final Thoughts
This pipeline won't handle Kubernetes or blue-green deployments. But for most side projects, small business apps, and even some production systems, it's enough.
Start simple. Get it working. Then iterate. That's the way to build something that actually works.
Have you built a similar pipeline? What's your minimal setup? Let me know in the comments.
Top comments (0)