DEV Community

Cloud Frontier
Cloud Frontier

Posted on

A Simple CI/CD Pipeline That Actually Works

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:

  1. Test - run automated checks
  2. Build - create an artifact
  3. 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"
Enter fullscreen mode Exit fullscreen mode

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 (usually deploy or ubuntu)
  • 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
Enter fullscreen mode Exit fullscreen mode

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

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 to dist. Adjust for your project.
  • Server path permissions: The deploy user needs write access to /var/www/myapp. Set it up once with sudo chown -R deploy:deploy /var/www/myapp.

Extending the Pipeline

Once this works, you can add:

  • Linting - add a lint job 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)