DEV Community

Cloud Frontier
Cloud Frontier

Posted on

A Simple CI/CD Pipeline That Works

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:

  1. Run tests on every push.
  2. Build an artifact if tests pass.
  3. 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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 becomes docker 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
Enter fullscreen mode Exit fullscreen mode

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.io once 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)