CI/CD Pipelines: From Zero to Production in Under 10 Minutes
TL;DR: A CI/CD pipeline automates build, test, security scan, and deploy so every push to main can safely reach production in minutes, not days. Start with GitHub Actions, keep the pipeline under 5 minutes, gate production behind a staging environment and a manual approval, and always keep a one-command rollback path ready before you need it.
Continuous Integration and Continuous Deployment (CI/CD) is the backbone of modern software delivery. When done right, it lets your team ship code confidently, multiple times a day, without the risk of breaking production.
What Is a CI/CD Pipeline?
A CI/CD pipeline automates the steps between writing code and running it in production. Every time a developer pushes code, the pipeline runs — building the app, running tests, scanning for vulnerabilities, and deploying if everything passes.
Setting Up with GitHub Actions
GitHub Actions is the easiest way to get started. Create a .github/workflows/deploy.yml file in your repo:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Tests
run: npm test
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
npm install --production
pm2 restart app
That's enough to go from a green test suite to a live deploy in under ten minutes end-to-end — most of that time is npm install, not the pipeline logic itself.
Containerising the Deploy
Once your app grows past a single VPS, package it as a container instead of running git pull directly on the server. A minimal Dockerfile for a Node app:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Extend the workflow to build and push the image, then pull it on the server instead of copying source directly:
- name: Build and push image
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Deploy image
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
docker stop app || true
docker rm app || true
docker run -d --name app -p 3000:3000 ghcr.io/${{ github.repository }}:${{ github.sha }}
The image tag is the git SHA, which means every deploy is traceable back to an exact commit — and rolling back is just re-running the deploy step with the previous SHA.
Adding a Staging Gate
Never let main deploy straight to production without a checkpoint. Split the workflow into two jobs, and use GitHub's environment feature to require manual approval before production:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: npm test
- name: Deploy to staging
run: ./scripts/deploy.sh staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy.sh production
In GitHub, set the production environment to require a reviewer under Settings → Environments. The pipeline runs staging automatically, then pauses and waits for a human to click "Approve" before touching production.
Key Stages Every Pipeline Needs
- Build — compile code, resolve dependencies
- Test — unit tests, integration tests, lint checks
- Security Scan — check for vulnerable packages with tools like Trivy or Snyk
- Deploy — push to staging first, then promote to production
Add the security scan as its own step so a vulnerable dependency fails the build loudly instead of quietly shipping:
- name: Scan for vulnerabilities
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
trivy fs --exit-code 1 --severity CRITICAL,HIGH .
Common Pipeline Failures (and Fixes)
- Flaky tests block every deploy — quarantine known-flaky tests into a separate job that reports but doesn't fail the pipeline, then fix them on a schedule instead of letting them block releases indefinitely.
- Secrets leak into logs — never
echoa secret directly; GitHub masks values referenced viasecrets.*automatically, but a variable copied into a plain shell variable first can bypass masking. - Slow pipelines get skipped — if a pipeline takes 20+ minutes, developers start merging without waiting for it. Cache
node_modules/ pip wheels and parallelise test suites to keep it under 5 minutes. - No rollback plan — if a deploy ships a bug, "just fix forward" under pressure is how bad incidents get worse. Always have a one-command rollback ready before you need it.
Monitoring Deploys and Rolling Back
Tag every deploy so you can identify exactly what's running and revert instantly if something breaks:
# Roll back to the previous image on the server
docker pull ghcr.io/org/app:$PREVIOUS_SHA
docker stop app && docker rm app
docker run -d --name app -p 3000:3000 ghcr.io/org/app:$PREVIOUS_SHA
Keep the last 5–10 image tags available in your registry so $PREVIOUS_SHA is always one command away, and wire a Slack or email notification into the deploy job so the whole team knows the moment a deploy — or a rollback — happens.
Handling Database Migrations Safely
Code deploys are easy to roll back — a database migration that already ran isn't. Run migrations as a separate, explicit step before the app restarts, and write every migration to stay backward-compatible with the previous app version for at least one release:
- name: Run migrations
run: |
ssh deploy@$VPS_HOST "cd /var/www/myapp && npx knex migrate:latest"
- name: Restart app
run: |
ssh deploy@$VPS_HOST "pm2 restart app"
Splitting the two steps means a failed migration stops the deploy before the app restarts with code that expects a schema that isn't there yet — instead of a partial deploy that's half old code, half new schema.
Notifying Your Team on Every Deploy
A deploy nobody knows happened is a deploy nobody can respond to quickly if it breaks something. Post to Slack (or Teams) as the final pipeline step, on both success and failure:
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Deploy ${{ job.status }}: ${{ github.repository }} @ ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: always() is the key detail — without it, the notification step only runs on success, and a failed deploy fails silently instead of paging anyone who could fix it quickly.
Choosing a CI Provider
GitHub Actions is the natural choice if your code already lives on GitHub — no separate account to manage, and secrets/environments are built in. GitLab CI is the equivalent if you're on GitLab, with very similar YAML syntax and the same environment-gating concepts. Jenkins still shows up in larger, older organisations that need to self-host the CI server itself, but for a new project today it adds operational overhead — patching, plugin management, agent capacity — that most teams don't need to take on. Pick the tool that matches where your code already lives rather than introducing a fourth system to your stack just to run tests.
Best Practices
Keep your pipeline fast — aim for under 5 minutes. Cache dependencies aggressively. Use environment-specific secrets stored in your CI provider, never in code. Add a manual approval step before production deploys on critical systems. Run the security scan and test suite in parallel jobs rather than sequentially to shave minutes off every run. And review pipeline run times monthly — a pipeline that quietly grew from 4 minutes to 18 over six months is a sign dependencies or tests need pruning before developers start routing around it.
At EkamOps, we build pipelines that give teams confidence to ship fast. Get in touch if you want us to review or build yours.
Want help applying this to your stack?
Book a free consultation →