Quick Answer

A production CI/CD pipeline in GitHub Actions has five stages: (1) lint & type-check, (2) test, (3) build & push container image, (4) deploy with health-check, (5) automatic rollback on failure. Setup time: 2–4 hours. It can save deployment time and catch many environment-specific regressions before release.

If you are deploying by SSH-ing to a server and running git pull, you are one keystroke away from breaking production at 2am. This guide is the pipeline I deploy on every project - from one-developer side projects to small SaaS teams.

Why CI/CD matters even for solo developers

The objection I hear most often is "I'm only one developer, I don't need a pipeline." This is wrong, and the math is simple:

  • Manual deploy: 5–15 minutes per push (pull, build, restart, smoke-test).
  • Automated deploy: 30 seconds of your time per push.
  • Setup cost: 2–4 hours, once.
  • Break-even: about 15 deploys. Most projects hit that in the first month.

Beyond time, a pipeline gives you three things you cannot get from manual deploys: tests on every change, atomic deploys (the new version replaces the old in one operation), and a known-good rollback path.

The five-stage architecture

Five-stage CI/CD pipeline: lint, test, build, deploy, health-check & rollback 1. Lint & types ~30s 2. Test unit + e2e ~2m 3. Build docker push ~1m 4. Deploy blue/green ~30s 5. Health-check auto-rollback on fail
Five stages, ~4–5 minutes end-to-end for a typical Node.js or Python project.

The reference workflow.yml

This is the file I put in .github/workflows/deploy.yml for a Node.js + Docker app. Adapt for your stack.

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test -- --ci --coverage

  build-and-push:
    needs: lint-and-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/setup-buildx-action@v3
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /srv/app
            docker compose pull
            docker compose up -d --remove-orphans
            # Wait for health-check
            for i in {1..30}; do
              if curl -fs http://localhost:3000/health > /dev/null; then
                echo "Healthy after ${i}s"
                exit 0
              fi
              sleep 1
            done
            echo "Health-check failed - rolling back"
            docker compose down
            docker tag ghcr.io/${{ github.repository }}:previous \
                       ghcr.io/${{ github.repository }}:latest
            docker compose up -d
            exit 1

Three things to notice:

  • Jobs run sequentially with needs: - no point building if tests fail.
  • The concurrency group prevents two deploys from racing if you push twice in quick succession.
  • The deploy step has its own health check and rollback logic - never trust that "deploy succeeded" means "service is healthy".

Secret management done right

Secrets are where most teams quietly break their security posture. The rules I follow on every project:

  1. Never commit secrets. Use .gitignore for .env files. Use gitleaks as a pre-commit hook to catch accidents.
  2. Store secrets in repo or org settings. Reference as ${{ secrets.NAME }}. They are encrypted at rest and never logged.
  3. Use environments for production secrets. The environment: production block in the deploy job lets you require manual approval and restricts which branches can use those secrets.
  4. Never echo a secret. Even masked, the lack of output is suspicious - design your scripts to not need to.
  5. Rotate quarterly. Set a calendar reminder. Bonus: connect to a secrets manager (Doppler, AWS Secrets Manager) via OIDC for fully ephemeral credentials.

Forks and pull requests

By default, GitHub Actions does not expose secrets to workflows triggered by pull requests from forks. This is correct and saves you from credential theft via a malicious PR. Make sure your deploy workflow only runs on push to your own branches, never on pull_request.

Testing strategy: what to run in CI

Bad CI runs everything and takes 30 minutes. Good CI runs the right tests in the right order and gives feedback in under 5.

Test typeWhenTarget time
Lint + type-checkEvery push< 30s
Unit testsEvery push< 2 min
Integration testsEvery push to main / PRs< 5 min
E2E smoke testsAfter deploy to staging< 3 min
Full E2E suiteNightly + before release10–30 min
Visual regressionOn UI PRs only< 5 min

Deployment strategies, ranked

From simplest to most sophisticated. Pick the simplest one your traffic allows.

1. Managed platform (Vercel, Netlify, Fly.io, Render)

For 80% of projects this is the right answer. Push to main, the platform handles atomic deploys, instant rollback, preview environments per PR. Your GitHub Actions workflow only needs to run tests - the platform's own integration handles deploy.

2. Rolling deploy via Docker Compose on a VPS

One Docker Compose file behind a reverse proxy (Caddy or Traefik). The workflow above shows the pattern. docker compose pull && docker compose up -d swaps the container atomically.

3. Blue/green with two environments

Two parallel environments (blue and green) behind a load balancer. Deploy to the inactive one, health-check, then flip the load-balancer target. Instant rollback by flipping back.

4. Kubernetes rolling deployment

Only if you genuinely need Kubernetes (rare - see my Docker vs Kubernetes guide). Kubernetes does rolling deploys natively with kubectl rollout.

The 3-layer rollback strategy

Three rollback mechanisms, used in priority order when something goes wrong:

  1. Automatic on health-check fail (the script above): the deploy script itself reverts to the previous image when the health-check fails. Fastest recovery; no human required.
  2. One-command manual rollback: a ./scripts/rollback.sh in the repo that redeploys the previous git tag. Used when the issue surfaces after the deploy reported success.
  3. Git revert + redeploy: git revert HEAD && git push. The pipeline rebuilds and deploys the reverted state. Slowest but always works.

Observability minimums on day one

  • Health endpoint at /health that returns 200 only if the app + DB + critical dependencies are reachable.
  • Structured logs shipped to a central destination (Loki, Datadog, Logtail).
  • Uptime monitor (UptimeRobot, BetterStack) pinging /health every minute with Slack alerts on failure.
  • Deploy notifications in Slack - the workflow posts on every deploy with sha, author, and status.
  • Error tracking (Sentry) wired in with the deploy sha for release tracking.

Common CI/CD mistakes (and what to do instead)

  • Running E2E tests on every commit → run lint+unit only, push E2E to nightly + staging.
  • No concurrency control → two deploys race, the slower one overwrites the faster one with stale code.
  • Trusting "deploy succeeded" without a health-check → silent breakage in production.
  • No deploy notifications → 6 hours to notice the pipeline has been broken since lunch.
  • Using latest tag only → impossible to roll back. Always tag with the git sha too.
  • Storing secrets in env files in the repo → one compromised laptop, total credential leak.

Conclusion: ship the pipeline, then ship features

Spend the first two hours of any new project setting up the pipeline. The pipeline pays for itself by the end of week one and pays dividends forever. The shape above - five stages, three rollback layers, real health checks, secret hygiene - is the boring, reliable answer.

Key takeaways

  • Five-stage pipeline: lint → test → build → deploy → health-check + auto-rollback.
  • Use GitHub repo/env secrets - never commit them, never echo them, rotate quarterly.
  • Tag every image with the git sha - latest alone is not enough for rollback.
  • Managed platforms (Vercel, Fly) solve 80% of deployment needs - only self-deploy when you need to.
  • Ship the health endpoint, uptime monitor, and Sentry on day one. Observability is not optional.
Share

Frequently asked questions

Common CI/CD questions from developers setting up their first production pipeline.

What is CI/CD?

CI/CD stands for Continuous Integration / Continuous Deployment (or Delivery). CI is the practice of automatically building and testing code every time it is pushed. CD is the practice of automatically deploying that tested code to staging or production. Together they remove human bottlenecks from the release process - code goes from commit to live, tested, in minutes.

Is GitHub Actions free?

GitHub Actions is free for all public repositories. For private repos, GitHub gives every account 2,000–3,000 minutes of free runtime per month on Linux runners (more for Pro/Team/Enterprise). This is enough to run dozens of deploys per day for most small teams. You only start paying when you exceed those minutes or need larger/macOS/Windows runners.

Do I need CI/CD as a solo developer?

Yes. A basic CI/CD pipeline takes about 2 hours to set up and saves time on every deployment for the life of the project. Even for solo work, the pipeline ensures every push runs your tests, builds the artifact, and deploys atomically - eliminating "works on my machine" bugs and freeing you from the manual SSH-and-pull dance.

What is the difference between CI and CD?

CI (Continuous Integration) means every code change automatically triggers a build and test run, surfacing problems early. CD has two meanings: Continuous Delivery means changes are automatically built, tested, and made deployable, with a human approving the final push to production. Continuous Deployment goes one step further and automatically deploys to production after tests pass. Most production pipelines use Continuous Delivery - same automation, plus a human gate before prod.

How do I do zero-downtime deployment with GitHub Actions?

The pattern is blue/green or rolling deployment. Build the new container image, tag it, push to a registry. The deploy step launches the new container alongside the old, runs a health-check, then swaps traffic via the load balancer or reverse proxy (Caddy, Traefik, nginx). The old container is drained gracefully. On managed platforms (Vercel, Fly.io, Render) this happens automatically - you push, they handle the swap.

How should I store secrets in GitHub Actions?

Use repository or organization secrets (Settings → Secrets and variables → Actions). Reference them in workflows as ${{ secrets.MY_SECRET }}. Never put secrets in repo files, never echo them to logs, and use environments with required-reviewer protection for production secrets. For high-security setups, integrate with a dedicated secret manager (Doppler, AWS Secrets Manager, HashiCorp Vault) via OIDC - no long-lived keys in GitHub at all.

How do I roll back a failed deployment?

Three layers. (1) Health-check the new deployment immediately; if it fails, the deploy step should exit non-zero and the platform keeps the previous version live. (2) Have a one-command rollback that redeploys the last known-good image tag. (3) Use git revert + push as the universal fallback - your pipeline rebuilds and deploys the reverted state. Tag every successful deploy with a git tag so you always know what to roll back to.

Need help shipping a production pipeline?

I build CI/CD pipelines, automate deployments, and migrate teams off manual deploys. If your pipeline is fragile or non-existent, book a 20-minute call and I'll map the upgrade path.

Book DevOps consult Read Docker vs K8s

Related guides