CI/CD Pipeline Design for Startups: From First Deploy to Production Confidence
Most CI/CD guides describe the happy path. This covers how to build a pipeline that actually earns trust over time, from a minimal GitHub Actions setup to layered test stages, preview deployments, security scanning, deploy gates, and the observability you need to know when things go wrong.
Most startups start the same way: a git push that triggers a manual SSH session, a deploy script that runs locally, maybe a Makefile target that works on the author’s machine. This works until it does not. The second engineer joins. A deployment breaks production on a Friday. Someone forgets to run the migration before pushing. You add a deploy script to the wiki. The wiki goes stale.
CI/CD is not primarily about automation. It is about encoding institutional knowledge into a process that runs the same way every time, regardless of who is pushing the code or what time it is. This article covers how to build that process progressively, starting with the minimal working pipeline and adding layers only when the pain justifies the complexity.
The Minimal Working Pipeline
Before layering on features, you need a pipeline that can do three things reliably: install dependencies, run tests, and deploy. That is it. Everything else is optimization.
Here is a GitHub Actions workflow that covers the basics for a Node.js/TypeScript project:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-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 typecheck
- run: npm test
- name: Build
run: npm run build
deploy:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: npm run deploy
A few things worth calling out here. npm ci instead of npm install because it installs exactly what is in package-lock.json and fails if the lockfile is out of sync. The cache: "npm" on the setup-node action caches the npm cache directory across runs, cutting install time from 30-60 seconds to 3-5 seconds on warm runs. The deploy job uses needs: build-and-test to create a dependency, ensuring you never deploy broken code.
This pipeline is about 40 lines and earns you: test gating on every PR, type checking, and automated deploys on merge. Ship this first. Resist the urge to add more until you know what is missing.
Layering on Test Stages
Once your pipeline is stable, the next thing that usually hurts is test runtime. You have 200 tests, they all run sequentially, and the pipeline takes 8 minutes. Two options: parallelize, or split into stages with different cost profiles.
The stage-based approach is more maintainable. You split tests into unit, integration, and end-to-end categories and run them at different points in the pipeline with different failure modes:
jobs:
lint-and-typecheck:
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 typecheck
unit-tests:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run test:unit -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
integration-tests:
runs-on: ubuntu-latest
needs: lint-and-typecheck
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb
run: npm run test:integration
e2e-tests:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
Unit and integration tests run in parallel after the lint pass. E2E tests run only on the main branch because they are slow and you do not want them blocking every PR. This structure means a failing unit test surfaces in 2 minutes instead of 8.
The services block in GitHub Actions is how you spin up a Postgres instance alongside your job without Docker Compose. It runs as a sidecar container in the same network namespace, available at localhost. Works for Redis, MySQL, Kafka, and most other backing services.
Preview Deployments
Preview deployments are the most high-leverage addition to a startup pipeline. Every PR gets a live URL. Stakeholders can review without cloning the repo. QA can test on mobile without setting up a dev environment. You can share a link in Slack instead of recording a Loom.
The implementation varies by hosting platform. Here is the pattern for Cloudflare Workers using Wrangler:
preview-deploy:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
if: github.event_name == 'pull_request'
outputs:
preview_url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Deploy preview
id: deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
PREVIEW_URL=$(npx wrangler deploy \
--env preview \
--name "my-app-pr-${{ github.event.number }}" \
2>&1 | grep -o 'https://[^ ]*')
echo "url=$PREVIEW_URL" >> "$GITHUB_OUTPUT"
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview deployed: ${{ steps.deploy.outputs.url }}`
})
Each PR gets a unique worker name (my-app-pr-123), which maps to a unique subdomain. You get real URLs with real TLS, not localhost tunnels. Clean up stale previews with a separate workflow triggered on PR close:
on:
pull_request:
types: [closed]
jobs:
cleanup-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler delete \
--name "my-app-pr-${{ github.event.number }}" \
--force
Security Scanning
Security scanning at the pipeline level catches three categories of problems before they reach production: dependency vulnerabilities, secrets accidentally committed to source, and static analysis findings. These are different tools with different trade-offs.
For dependency vulnerabilities, npm audit is already available and free. Add it as a step:
- name: Audit dependencies
run: npm audit --audit-level=high
--audit-level=high fails only on high and critical vulnerabilities, ignoring low and moderate. Failing on all vulnerabilities will create noise and alert fatigue on day one. Tune this as your tolerance for noise develops.
For secrets detection, trufflesecurity/trufflehog and gitleaks both run as GitHub Actions. Gitleaks is simpler to get started with:
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 fetches the full history instead of a shallow clone, allowing Gitleaks to scan all commits on the branch, not just the latest. A shallow clone would miss secrets added and “deleted” in earlier commits.
For static analysis on TypeScript, ESLint with @typescript-eslint handles most of what you need. Add eslint-plugin-security for a lightweight pass at common security anti-patterns:
// eslint.config.mjs
import tseslint from "typescript-eslint";
import security from "eslint-plugin-security";
export default tseslint.config(
...tseslint.configs.recommended,
security.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"security/detect-object-injection": "warn",
"security/detect-non-literal-regexp": "error",
},
}
);
Deploy Gates
A deploy gate is a condition that must be true before the pipeline promotes to the next environment. Gates encode the answer to the question: “what does passing look like?”
The simplest gate is the one already in our pipeline: tests pass before deploy. More sophisticated gates check coverage thresholds, performance budgets, and integration health.
Here is a coverage gate using a TypeScript script to fail the build below a threshold:
// scripts/check-coverage.ts
import { readFileSync } from "fs";
interface CoverageSummary {
total: {
lines: { pct: number };
statements: { pct: number };
branches: { pct: number };
functions: { pct: number };
};
}
const THRESHOLDS = {
lines: 80,
statements: 80,
branches: 70,
functions: 80,
};
const summary: CoverageSummary = JSON.parse(
readFileSync("coverage/coverage-summary.json", "utf-8")
);
let failed = false;
for (const [metric, threshold] of Object.entries(THRESHOLDS)) {
const actual = summary.total[metric as keyof typeof summary.total].pct;
if (actual < threshold) {
console.error(
`Coverage gate failed: ${metric} is ${actual.toFixed(1)}% (required: ${threshold}%)`
);
failed = true;
}
}
if (failed) {
process.exit(1);
}
console.log("Coverage gates passed.");
Add it to the pipeline after tests:
- run: npx tsx scripts/check-coverage.ts
For performance budgets, Lighthouse CI integrates directly with GitHub Actions and can block a PR if bundle size or performance score degrades past a threshold. That is useful once you have a frontend with enough complexity that bundle regressions become a real risk.
GitHub Actions vs GitLab CI vs Dagger
At some point you will evaluate whether GitHub Actions is the right choice. Here is the honest comparison for startups:
| Dimension | GitHub Actions | GitLab CI | Dagger |
|---|---|---|---|
| Setup time | Minutes | Hours (self-hosted) / Minutes (cloud) | Hours |
| YAML complexity | Medium | High | None (code) |
| Runner cost at scale | High | Lower (self-hosted) | Depends on provider |
| Portability | Low (GH-specific) | Medium | High |
| Local pipeline execution | No | No | Yes |
| Ecosystem / marketplace | Excellent | Good | Growing |
| Secret management | Native | Native | External |
| Matrix builds | Yes | Yes | Yes |
| Caching | Good | Excellent | Depends |
GitHub Actions is the right default for startups on GitHub. The marketplace of pre-built actions eliminates a significant amount of boilerplate. The YAML is not great, but it is learnable. The runner costs matter at scale but not at startup stage.
GitLab CI has better caching semantics and the YAML is more consistent once you understand it. The main argument for it is if you are already on GitLab or if you plan to run self-hosted runners to reduce cost at scale.
Dagger is architecturally different: you write your pipeline as code (TypeScript, Go, Python) rather than YAML, and Dagger compiles it down to a DAG that runs in any container runtime. The value proposition is local pipeline execution (run the exact same pipeline locally as in CI) and portability across CI providers. The cost is a steeper learning curve and a smaller ecosystem. It makes sense when you have complex pipelines that are painful to debug remotely, or when you want to run CI locally during development without emulating the CI environment manually.
Most startups should pick GitHub Actions, run it for a year, and re-evaluate based on actual pain points.
Rollback Triggers
Automated rollback is where most teams get overconfident. The instinct is to build a system that detects errors after deploy and automatically reverts. The reality is that automated rollback requires very precise signal, and bad signal causes more problems than it solves.
The reliable pattern is a structured manual rollback with a one-command trigger:
// scripts/rollback.ts
import { execSync } from "child_process";
const PREVIOUS_SHA =
process.env.ROLLBACK_SHA ||
execSync("git rev-parse HEAD~1").toString().trim();
console.log(`Rolling back to ${PREVIOUS_SHA}`);
// Tag the rollback for traceability
execSync(
`git tag rollback-${Date.now()} ${PREVIOUS_SHA}`,
{ stdio: "inherit" }
);
// Trigger deploy of previous version
execSync(
`npm run deploy -- --version ${PREVIOUS_SHA}`,
{ stdio: "inherit", env: { ...process.env } }
);
Pair this with a GitHub Actions workflow triggered by a manual dispatch or a Slack command through your incident tooling. The key is that a rollback should be a one-click operation that any engineer can execute in 30 seconds, not a multi-step manual process.
For automated rollback, the safest entry point is health-check-based rollback at the deploy step itself: deploy the new version, hit a health endpoint, if it returns non-200 within 30 seconds, redeploy the previous artifact. This is narrow enough to be reliable:
- name: Deploy with health check
run: |
npm run deploy
sleep 10
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed ($STATUS), rolling back"
npm run rollback
exit 1
fi
Artifact Management
Every build should produce a versioned artifact. For containerized services, that is a Docker image tagged with the commit SHA. For serverless, it is a build artifact uploaded to object storage. The commit SHA is the canonical identifier that connects a deploy to a specific point in source history.
- name: Build and push Docker image
run: |
IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
echo "IMAGE=$IMAGE" >> "$GITHUB_ENV"
- name: Deploy
run: |
# Your orchestration tool uses the pinned image tag
kubectl set image deployment/my-app app="$IMAGE"
Using latest as a tag breaks traceability. When something goes wrong at 2am, you want to know exactly what code is running, and latest tells you nothing useful. SHA tags tell you everything.
Retention policy matters. Keep the last 30 artifacts on the main branch. Keep artifacts from tagged releases indefinitely. Delete everything else. Storage is cheap, but unbounded storage growth becomes a maintenance problem.
Pipeline Observability
A pipeline is itself a system that can be slow, flaky, or broken. Treat it as one. The metrics that matter are:
- Pipeline duration: how long does a full run take? If it crosses 15 minutes, developers stop waiting and context-switch. Measure and set an alert.
- Flakiness rate: what percentage of runs fail on retry? A flaky test is a test that erodes trust in the pipeline. Track flaky tests by name and fix them aggressively.
- Deployment frequency: how many times per day does the pipeline deploy to production? This is a leading indicator of team velocity.
- Time to green: from push to passing pipeline on the main branch, how long does it take? This determines how fast you can iterate.
GitHub Actions has a built-in API for all of this. Here is a TypeScript snippet that pulls run duration data:
import { Octokit } from "@octokit/rest";
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
async function getPipelineDurations(
owner: string,
repo: string,
workflowId: string,
days: number = 7
): Promise<{ runId: number; durationSeconds: number; conclusion: string }[]> {
const since = new Date();
since.setDate(since.getDate() - days);
const { data } = await octokit.actions.listWorkflowRuns({
owner,
repo,
workflow_id: workflowId,
created: `>=${since.toISOString()}`,
per_page: 100,
});
return data.workflow_runs
.filter((run) => run.updated_at && run.created_at)
.map((run) => ({
runId: run.id,
durationSeconds:
(new Date(run.updated_at!).getTime() -
new Date(run.created_at).getTime()) /
1000,
conclusion: run.conclusion ?? "unknown",
}));
}
Pipe this into a dashboard or a weekly Slack summary. Pipeline metrics are easy to instrument and make slow drift visible before it becomes a crisis.
What to Build When
The staging principle here is to add pipeline complexity only when you can name the specific problem it solves. Here is the order that makes sense for most TypeScript/Node.js startups:
- Week 1: Minimal pipeline. Tests gate deploys on main. Done.
- Month 1: Split test stages. Add coverage check. Fix the flaky tests you discover.
- Month 2: Preview deployments on PRs. This accelerates review cycles more than anything else.
- Month 3: Secret scanning. Dependency audit gating. These are cheap and catch real problems.
- Month 6: Performance budgets, Lighthouse CI, bundle size gates. Only when you have a frontend users are actually using.
- Year 1: Evaluate Dagger or GitLab if GitHub Actions cost or YAML complexity is genuinely painful.
The teams that build the perfect CI/CD pipeline on day one and the teams that ship product on day one are different teams. Get the minimal pipeline in place, ship things, and let the pain tell you what to add next.
The pipeline is not the product. But it is the foundation that lets you ship the product without fear.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.