Back to Blog
Cloud & DevOps

DevOps for Startups: CI/CD Without the Overhead

How to set up a production-grade CI/CD pipeline on a startup budget using GitHub Actions, Docker, and a $10/month VPS.

Adsesugh Igba
May 8, 2025
8 min read

The Startup DevOps Dilemma

Every startup faces the same tension: you need reliable deployments and infrastructure, but you can't afford a dedicated DevOps engineer or expensive cloud services. The good news? Modern tooling has democratized DevOps to the point where a single developer can set up a production-grade pipeline in an afternoon.

This guide walks through the exact setup we use for early-stage startups — battle-tested, budget-friendly, and ready to scale when the time comes.

The Stack

ComponentToolMonthly Cost
CI/CDGitHub ActionsFree (2,000 min/month)
Container RegistryGitHub Container RegistryFree
ServerHetzner VPS (CPX21)~$10
Reverse ProxyCaddyFree
SSLCaddy (automatic Let's Encrypt)Free
MonitoringUptime Kuma + GrafanaFree (self-hosted)

Total: ~$10/month for a setup that handles 10,000+ daily active users comfortably.

Step 1: Dockerize Your Application

Start with a multi-stage Dockerfile that produces a lean production image:

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Key principles:

  • Multi-stage builds keep the final image small (typically under 200MB)
  • Non-root user for security
  • Standalone output from Next.js eliminates the need for node_modules in production

Step 2: GitHub Actions Workflow

Create a workflow that builds, tests, and deploys on every push to main:

name: Deploy to Production
on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

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 run type-check
      - run: npm test -- --run

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            docker compose -f /opt/app/docker-compose.prod.yml up -d --force-recreate

Step 3: Server Setup with Caddy

Caddy handles reverse proxying and automatic HTTPS with zero configuration:

# /etc/caddy/Caddyfile
app.yourdomain.com {
    reverse_proxy localhost:3000
    encode gzip zstd
    
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
    }
}

That's it. Caddy automatically provisions and renews SSL certificates from Let's Encrypt.

Step 4: Docker Compose for Production

# /opt/app/docker-compose.prod.yml
version: "3.8"
services:
  app:
    image: ghcr.io/your-org/your-app:latest
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - NODE_ENV=production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=${DB_NAME}
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}

volumes:
  pgdata:

Step 5: Monitoring on a Budget

Self-host Uptime Kuma for uptime monitoring and Grafana with Prometheus for metrics:

  • Uptime Kuma sends alerts to Slack/Discord when your site goes down
  • Grafana dashboards show CPU, memory, and request metrics
  • Both run alongside your app on the same $10 VPS

Zero-Downtime Deployments

For zero-downtime deploys without Kubernetes, use Docker's rolling update strategy:

#!/bin/bash
# deploy.sh - Zero-downtime deployment script
set -e

IMAGE="ghcr.io/your-org/your-app:latest"

# Pull new image
docker pull $IMAGE

# Start new container alongside old one
docker compose -f docker-compose.prod.yml up -d --no-deps --scale app=2 --no-recreate app

# Wait for new container to be healthy
sleep 10

# Remove old container
docker compose -f docker-compose.prod.yml up -d --no-deps --scale app=1 --no-recreate app

When to Graduate

This setup handles significant traffic — we've run applications serving 50,000+ monthly visitors on similar infrastructure. You should consider upgrading when:

  • You need horizontal scaling — multiple servers behind a load balancer
  • Compliance requirements demand managed services (SOC 2, HIPAA)
  • Team grows beyond 5 engineers and you need environment isolation
  • Database needs exceed what a single Postgres instance can handle

At that point, move to managed services like Vercel, Railway, or AWS ECS. The containerized setup means migration is straightforward — your Docker images deploy anywhere.

Final Thoughts

DevOps doesn't have to be complex or expensive. A well-structured Docker setup with GitHub Actions gives you:

  • Automated testing on every commit
  • One-command deployments
  • Automatic HTTPS
  • Health monitoring
  • All for less than the cost of a coffee subscription

Start simple, automate early, and scale when the metrics demand it — not before.

Ready to build something extraordinary?

We help startups and enterprises ship production-grade software powered by AI. Let's discuss your next project.

Get In Touch
    DevOps for Startups: CI/CD Without the Overhead — Sughware Blog | Sughware Technologies