Quick Answer

This production-oriented GitHub Actions pipeline uses five stages: (1) lint & type-check, (2) test, (3) build & push container image, (4) deploy with health-check, (5) rollback on failure. Adapt the stages to your stack and hosting platform; setup effort and run time depend on the project.

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 presents a pipeline for one-developer side projects and 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." Team size is not the deciding factor; repeatability and deployment risk are:

  • List each manual step: pull, build, restart, smoke-test, and rollback preparation.
  • Automate checks that should run the same way on every change.
  • Measure workflow duration and maintenance cost in your own repository.
  • Keep manual approval where the release risk justifies it.

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 project-dependent 2. Test unit + integration 3. Build build + push image 4. Deploy platform strategy 5. Health-check auto-rollback on fail
Five stages; actual duration depends on dependencies, tests, caching, image size, runners, and deployment target.

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. These are the rules I recommend:

  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 }}. GitHub documents how Actions secrets are configured and redacted; still avoid printing sensitive values.
  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

Good CI runs the checks that match the risk and gives feedback soon enough to be useful. Establish targets from your repository's baseline rather than borrowing a universal duration.

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 → failures can go unnoticed until someone checks the environment.
  • 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

Set up the smallest useful pipeline early, then add stages as the project gains tests, deployment risk, and operational requirements. The shape above - staged checks, rollback layers, real health checks, and secret hygiene - is a reliable starting point, not a fixed recipe.

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-hosted runners are free for public repositories, subject to GitHub's usage limits. Private repositories receive an included allowance based on the account plan, and additional usage is billed. Self-hosted runners have separate infrastructure costs. Check GitHub's current Actions billing documentation because allowances and rates can change.

Do I need CI/CD as a solo developer?

It is often useful when a project has tests or repeatable deployments. A pipeline can run the same checks on each change, build a consistent artifact, and provide a documented deployment and rollback path. Setup effort and payoff depend on the stack, hosting platform, test suite, and release frequency, so start with the smallest workflow that removes a real manual risk.

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