I used to overcomplicate CI/CD. I'd spend days wiring up Jenkins, configuring agents, and debugging YAML that nobody understood. Then I realized: a simple pipeline that works beats a fancy one that doesn't. Here's the setup I use for most projects now.
The Core Idea
A CI/CD pipeline has three jobs:
- Run tests on every push.
- Build an artifact if tests pass.
- Deploy that artifact to a server.
That's it. No Kubernetes, no service mesh. Just enough automation to stop manually SSHing into servers.
Tools I Use
- GitHub Actions for the pipeline (free for public repos, generous for private).
- Docker to package the app consistently.
- A single VPS (DigitalOcean, Hetzner, whatever) running Docker.
- GitHub Container Registry to store images.
Total cost: about $5/month. Setup time: an afternoon.
Step 1: Dockerfile
Make your app buildable in one command.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
If you can't docker build locally, fix that first. Everything else depends on it.
Step 2: The Workflow
Create .github/workflows/deploy.yml:
name: CI/CD
on:
push:
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-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:latest
docker stop app || true
docker rm app || true
docker run -d --name app --restart unless-stopped \
-p 80:3000 ghcr.io/${{ github.repository }}:latest
Set SSH_HOST, SSH_USER, and SSH_KEY in your repo secrets. Generate a dedicated deploy key, don't reuse your personal one.
Step 3: Make It Safe
A few things that save me from 2am incidents:
-
Tag images with the commit SHA, not just
latest. Rollback becomesdocker run ...:<sha>. - Add a healthcheck to the Dockerfile so Docker can restart a broken container.
- Run migrations before swapping containers, and make them backward compatible.
- Keep one previous image on the server so rollback is instant.
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:3000/health || exit 1
What I Skipped (On Purpose)
- Kubernetes. Overkill for a single app.
- Blue/green deploys. Nice, but a 5-second restart is fine for most projects.
- A staging environment. I use a preview branch if I need one.
You can add these later when the pain is real. Premature infrastructure is just tech debt with a nicer name.
When This Breaks
It will, eventually. Common issues:
-
SSH action times out. Check firewall rules and that the deploy key is in
authorized_keys. -
Image pull fails on server. Run
docker login ghcr.ioonce on the server with a PAT. - Tests pass locally, fail in CI. Pin your Node version and check for missing env vars.
The Point
The best pipeline is the one you understand end to end. This one is about 60 lines of YAML. You can read it in five minutes, debug it in ten, and it gets your code to production on every push.
Start here. Add complexity only when it earns its keep.
Top comments (0)