Back to Blog

There’s a moment in every growing web project where manual deployment becomes terrifying. You’re FTPing files to a production server, hoping you didn’t miss anything. Or SSH-ing into a server, running a series of commands from memory, praying nothing breaks. One wrong step and the site goes down. One forgotten file and a feature breaks for every user.

This is the stage where most small teams need CI/CD: Continuous Integration and Continuous Deployment. It sounds like enterprise jargon, but the concept is simple: automate the process of testing, building, and deploying your code so that shipping updates is reliable, repeatable, and boring. Boring is good when it comes to deployments. For the full walkthrough, start with the questions we work through before building custom web applications.

We’ve set up CI/CD pipelines for projects ranging from simple static sites to complex web applications. And in every case, the reaction from the team is the same: “Why didn’t we do this sooner?”

Here’s how CI/CD works, what it looks like for small teams, and how to set it up without over-engineering.

What CI/CD Actually Means

Continuous Integration (CI) is the practice of automatically testing and validating every code change when it’s pushed to your repository. When a developer pushes code, the CI system:

  • Runs automated tests
  • Checks code formatting and linting
  • Builds the project to ensure it compiles
  • Runs any other validation checks (performance budgets, accessibility audits, etc.)

If any check fails, the developer is notified immediately, before the code reaches production.

Continuous Deployment (CD) is the practice of automatically deploying code to your server after it passes all CI checks. When code is merged to the main branch:

  • The CI checks run
  • If they pass, the code is automatically built and deployed to production
  • The new version is live without anyone manually touching a server

Continuous Delivery (also CD, confusingly) is a slightly more conservative approach where code is automatically built and prepared for deployment but requires a manual approval before going live. This is a good middle ground for teams that aren’t comfortable with fully automated production deployments yet.

Why Small Teams Benefit the Most

Large companies have dedicated DevOps teams to manage infrastructure and deployments. Small teams don’t. Which is exactly why CI/CD matters more for small teams: it replaces an entire role’s worth of manual work with automation.

Here’s what we see before CI/CD:

  • One person is the deployment bottleneck. Usually the senior developer or agency owner. If they’re unavailable, nothing ships.
  • Deployments are stressful. Manual processes are error-prone. People hold their breath during deployments.
  • Bug fixes are slow. If deploying is a big deal, teams batch changes together, which means bug fixes wait for the next deployment window.
  • Testing is inconsistent. Without automated checks, testing depends on someone remembering to check everything.
  • Rollbacks are terrifying. If something breaks, reverting to a previous version is a manual, panicked process.

After CI/CD:

  • Anyone can ship. Merge your code, and it deploys. No special knowledge needed.
  • Deployments are boring. The process is identical every time. Nothing to remember, nothing to worry about.
  • Bug fixes are fast. Push a fix, pass the checks, it’s live in minutes.
  • Testing is automatic. Every change is validated. No human memory required.
  • Rollbacks are one click. Revert the merge, and the previous version deploys automatically.
Dashboards and portals turn scattered data into one source of truth.
Dashboards and portals turn scattered data into one source of truth.

GitHub Actions: The Default Choice

For small teams already using GitHub (which is most of them), GitHub Actions is the natural choice for CI/CD. It’s built into GitHub, free for public repositories and generous with free minutes for private ones, and flexible enough to handle virtually any workflow.

A Basic CI Pipeline

Here’s a minimal GitHub Actions workflow that runs tests and builds a project on every push:

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

This workflow:

  • Triggers on every push to main or develop, and on every pull request to main
  • Checks out the code
  • Sets up Node.js with dependency caching
  • Installs dependencies
  • Runs the linter (catches code style issues)
  • Runs the test suite (catches bugs)
  • Builds the project (catches compilation errors)

If any step fails, the workflow fails, and the developer sees exactly what went wrong.

Adding Deployment

Extending the pipeline to deploy after successful CI:

yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build
      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: build
          path: dist/

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/

      - name: Deploy to server
        uses: easingthemes/ssh-deploy@v5
        with:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_KEY }}
          REMOTE_HOST: ${{ secrets.HOST }}
          REMOTE_USER: ${{ secrets.USER }}
          SOURCE: "dist/"
          TARGET: "/var/www/mysite/"

The deploy job only runs after the test job succeeds. If tests fail, nothing is deployed. This two-stage approach ensures broken code never reaches production.

Automated Testing: The Foundation of CI

CI is only as useful as your tests. Without tests, CI is just “continuous building”: it’ll catch compilation errors but nothing else.

Types of Tests Worth Having

You don’t need comprehensive test coverage to benefit from CI. Start with the tests that catch the most common issues:

Unit Tests

Small tests for individual functions and components. They run fast and catch logic bugs:

javascript
// Example: testing a price calculation function
test('calculates total with tax', () => {
  expect(calculateTotal(100, 0.08)).toBe(108);
});

test('applies discount before tax', () => {
  expect(calculateTotal(100, 0.08, 0.10)).toBe(97.20);
});

Integration Tests

Tests that verify multiple parts of your system work together. For web applications, this might mean testing an API endpoint end-to-end:

javascript
test('POST /api/orders creates an order', async () => {
  const response = await request(app)
    .post('/api/orders')
    .send({ productId: 1, quantity: 2 });

  expect(response.status).toBe(201);
  expect(response.body.total).toBe(49.98);
});

End-to-End Tests

Tests that simulate real user behavior in a browser. These are slower but catch issues that unit tests miss:

javascript
test('user can submit contact form', async ({ page }) => {
  await page.goto('/contact');
  await page.fill('#name', 'Test User');
  await page.fill('#email', 'test@example.com');
  await page.fill('#message', 'Hello');
  await page.click('button[type="submit"]');
  await expect(page.locator('.success-message')).toBeVisible();
});

Our Testing Strategy for Small Projects

Always
Linting (catches syntax errors and style issues)
Always
Build verification (catches compilation errors)
When possible
Unit tests for business logic (calculations, data transformations, validation rules)
For web apps
Integration tests for critical API endpoints
For complex UIs
End-to-end tests for critical user flows (sign up, checkout, form submission)

You don’t need 100% test coverage. Test the things that would hurt the most if they broke.

Staging Environments

A staging environment is a copy of your production setup where you can test changes before they go live. It’s the safety net between your development environment and the real world.

Why Staging Matters

Your development machine and production server are different environments. Things that work locally can break in production due to:

  • Different operating system or runtime versions
  • Different environment variables or configurations
  • Different database states
  • Different server configurations (SSL, redirects, caching)
  • Third-party service integration differences

A staging environment that mirrors production catches these issues before your users see them.

Setting Up Staging with CI/CD

Our typical branching and deployment strategy:

  1. Feature branches: developers work on feature branches
  2. Pull request to develop: CI runs tests. Team reviews code. Merge to develop triggers deployment to staging.
  3. Testing on staging: team and client verify changes in a production-like environment
  4. Pull request from develop to main: another round of CI checks. Merge to main triggers deployment to production.
yaml
# Simplified multi-environment deployment
on:
  push:
    branches:
      - develop  # deploy to staging
      - main     # deploy to production

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Set environment
        run: |
          if [ "${{ github.ref }}" = "refs/heads/main" ]; then
            echo "DEPLOY_TARGET=production" >> $GITHUB_ENV
          else
            echo "DEPLOY_TARGET=staging" >> $GITHUB_ENV
          fi
      # ... build and deploy to the appropriate target

Affordable Staging Options

Staging doesn’t need to be expensive:

  • Same server, different directory: a subdomain like staging.yoursite.com pointing to a different document root. Simple, cheap, but doesn’t catch server-level configuration issues.
  • DigitalOcean / Vultr droplet: a small VPS ($5-12/month) that mirrors your production server configuration.
  • Vercel / Netlify preview deployments: for static sites and JAMstack applications, every pull request automatically gets its own preview URL. Free on most plans.
  • Docker-based staging: if production runs in Docker, spin up an identical container for staging. Ensures environment parity.

Deployment Strategies

How you deploy matters as much as what you deploy. Here are the strategies appropriate for small teams.

Direct Deployment (Replace in Place)

The simplest approach: upload new files, replacing old ones. Works fine for simple static sites and small applications where a few seconds of potential downtime during deployment is acceptable.

Blue-Green Deployment

Two identical environments (blue and green) behind a load balancer or reverse proxy. One serves live traffic while the other receives the new deployment. Once the new deployment is verified, you switch traffic to it.

Advantage: Zero downtime. Instant rollback by switching back to the previous environment. Disadvantage: Requires twice the infrastructure (though for small applications, this cost is minimal).

Rolling Deployment

If you have multiple server instances, you deploy to one at a time while the others continue serving traffic. Once the first instance is healthy, you deploy to the next.

Advantage: Zero downtime with less infrastructure than blue-green. Disadvantage: For a brief period, some users hit the old version while others hit the new version. Usually not an issue for small applications.

Our Default for Small Teams

For most of our projects, we use a simple approach:

  1. Build and test in CI
  2. Deploy build artifacts to the server (via SSH, rsync, or platform-specific tooling)
  3. Run any necessary migration or post-deployment scripts
  4. Verify the deployment with a health check
  5. Notify the team via Slack or email

For applications where downtime isn’t acceptable, we implement blue-green with a simple Nginx configuration swap.

Rollback Plans

Every deployment should have a rollback plan. “What do we do if this breaks production?”

Automated Rollback

The CI/CD pipeline can include a post-deployment health check. If the check fails, the pipeline automatically reverts to the previous version:

yaml
- name: Health check
  run: |
    response=$(curl -s -o /dev/null -w "%{http_code}" https://yoursite.com/health)
    if [ "$response" != "200" ]; then
      echo "Health check failed. Rolling back."
      # Trigger rollback
      exit 1
    fi

Git-Based Rollback

Since every deployment is tied to a git commit, rolling back means deploying a previous commit. With CI/CD, this can be as simple as:

bash
git revert HEAD
git push origin main

The CI/CD pipeline picks up the revert commit, runs tests, and deploys the previous state.

Database Rollback Considerations

If your deployment includes database migrations, rollback becomes more complex. You need to:

  • Write reversible migrations (every “up” migration has a corresponding “down”)
  • Test rollback migrations before deploying
  • Consider whether data changes since the migration would be affected by reverting

For critical migrations, we deploy the database changes separately from the application code, verify they work, and then deploy the application update.

Practical Tips for Small Teams

Start Simple

Don’t build a complex CI/CD pipeline from day one. Start with:

  1. Automated linting and building on every push
  2. Manual deployment triggered by a button or merge to main
  3. Add tests incrementally as you write new features
  4. Add staging when you have a client or stakeholder who needs to review changes

Use Platform Features

If you’re deploying to Vercel, Netlify, or similar platforms, CI/CD is built in. Push to main and it deploys. Push to a branch and it creates a preview. Don’t build custom pipelines to replicate what the platform already provides.

Keep Builds Fast

A CI pipeline that takes 20 minutes destroys developer productivity. Keep builds under five minutes:

  • Cache dependencies (node_modules, etc.)
  • Run tests in parallel when possible
  • Only build what changed (incremental builds)
  • Use faster test runners (Vitest instead of Jest, for example)

Secure Your Secrets

Never store passwords, API keys, or SSH keys in your code or CI configuration files. Use your CI platform’s secrets management:

  • GitHub Actions: Settings > Secrets and Variables > Actions
  • Store database credentials, API keys, SSH keys, and deployment tokens as encrypted secrets
  • Reference them in workflows as ${{ secrets.SECRET_NAME }}

Monitor After Deployment

Deployment isn’t done when the files are on the server. After every deployment:

  • Check application logs for errors
  • Monitor error rates (Sentry, LogRocket, or similar)
  • Watch Core Web Vitals for performance regressions
  • Check that critical user flows are working

Catch issues in the first five minutes, not when a customer reports them.

What It Costs

For a small team:

GitHub Actions
2,000 free minutes/month for private repos. Most small projects never exceed this.
Staging server
$5-12/month for a basic VPS, or free with platforms like Vercel/Netlify.
Monitoring
Sentry free tier covers 5,000 events/month. Usually enough for small projects.
Time investment
Setting up a basic CI/CD pipeline takes a day or two. The time saved on every subsequent deployment pays this back within weeks.

Keep Reading