DEV Community

Cloud Frontier
Cloud Frontier

Posted on

A Simple CI/CD Pipeline That Actually Works

The Problem with Over-Engineered Pipelines

I've seen teams spend weeks building elaborate CI/CD pipelines with Kubernetes, multiple stages, and fancy dashboards. Then they realize the pipeline is harder to maintain than the app itself. The truth is, most projects don't need that. They need a pipeline that runs tests, builds an artifact, and deploys it somewhere. Here's a simple, reliable approach that works for small to medium projects.

The Core Principles

Before writing any config, keep these in mind:

  • Keep it linear: One flow from commit to deploy, no branching logic unless absolutely necessary.
  • Fail fast: The pipeline should stop at the first failing step, so you know exactly what broke.
  • Use versioned artifacts: Always tag your builds with the commit SHA or a version number. It makes rollbacks trivial.
  • Make deployment idempotent: Running the deploy step twice should be safe.

The Pipeline Stages

I'll use GitHub Actions as an example, but the same logic applies to GitLab CI, CircleCI, or Jenkins. The pipeline has four stages:

  1. Test, run unit tests and linting.
  2. Build, create a Docker image or a compiled binary.
  3. Push, upload the artifact to a registry.
  4. Deploy, update the server or service.

A Minimal GitHub Actions Workflow

Here's a complete .github/workflows/deploy.yml that does all of this for a Node.js app deployed to a VPS via SSH.

name: CI/CD

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Push to registry
        run: |
          echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.example.com -u ${{ secrets.REGISTRY_USER }} --password-stdin
          docker push myapp:${{ github.sha }}

      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull myapp:${{ github.sha }}
            docker stop myapp || true
            docker rm myapp || true
            docker run -d --name myapp -p 80:3000 myapp:${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

Why This Works

  • Single job: Everything runs in one job, so you don't have to deal with artifact passing between jobs. It's slower but simpler.
  • Image tag by SHA: The image is tagged with the commit SHA, so you always know what's running.
  • Deploy script is safe: docker stop and docker rm are wrapped in || true so a missing container doesn't fail the deploy.
  • Secrets are managed: No hardcoded credentials; everything is in GitHub secrets.

Handling Deploy Failures

What if the deploy fails? The pipeline will show a red X, and you can check the logs. But you should also think about rollback. With SHA-tagged images, rollback is just a matter of re-running the deploy step with the previous SHA. You can even add a manual rollback job:

on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: 'Image tag to deploy'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy specific tag
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull myapp:${{ github.event.inputs.image_tag }}
            docker stop myapp || true
            docker rm myapp || true
            docker run -d --name myapp -p 80:3000 myapp:${{ github.event.inputs.image_tag }}
Enter fullscreen mode Exit fullscreen mode

When to Add More Complexity

This simple pipeline covers 80% of needs. But if you hit any of these, consider extending:

  • Multiple environments: Add a staging and production job, but keep them separate workflows triggered manually or by tags.
  • Database migrations: Run them as a separate step before deploy, but make them idempotent.
  • Blue-green or zero-downtime deploys: Use a load balancer and swap containers, but that's a bigger change.

Final Advice

Start with the simplest thing that works. You can always add more later. The key is to make your pipeline reliable and boring. If you're spending more time on the pipeline than on the product, you're doing it wrong.

I've used this exact pattern in several projects, and it's saved me tons of headaches. Try it, and you'll see how quickly you forget about deployments entirely.

Top comments (0)