Deploying Web Applications: CI/CD Pipelines for Small Teams
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.
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:
# .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 buildThis 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:
# .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:
// 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:
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:
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
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:
- Feature branches: developers work on feature branches
- Pull request to
develop: CI runs tests. Team reviews code. Merge to develop triggers deployment to staging. - Testing on staging: team and client verify changes in a production-like environment
- Pull request from
developtomain: another round of CI checks. Merge to main triggers deployment to production.
# 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 targetAffordable 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:
- Build and test in CI
- Deploy build artifacts to the server (via SSH, rsync, or platform-specific tooling)
- Run any necessary migration or post-deployment scripts
- Verify the deployment with a health check
- 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:
- 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
fiGit-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:
git revert HEAD
git push origin mainThe 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:
- Automated linting and building on every push
- Manual deployment triggered by a button or merge to main
- Add tests incrementally as you write new features
- 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: