I used to deploy manually. SSH into the server, pull the code, run the build, restart the service. It worked until it didn’t — a missed dependency broke production at 11 PM. I built a CI/CD pipeline with GitHub Actions and haven’t deployed manually since. Here is the exact setup.
What This Pipeline Does
Every push to main triggers:
- Lint — check code quality
- Type check — catch type errors
- Test — run the test suite
- Build — create production bundle
- Deploy — push to production
If any step fails, the pipeline stops and I get a notification.
The Complete Workflow File
Here is my .github/workflows/ci.yml:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
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
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx tsc --noEmit
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 test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
build:
needs: [lint, type-check, 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 build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to production
run: echo "Deploy step — replace with your deployment"
Breaking It Down
1. Caching Dependencies
The cache: ‘npm’ line is crucial. Without it, every job downloads all dependencies from scratch. With caching, subsequent runs are 50% faster.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # This caches node_modules
2. Using npm ci Instead of npm install
npm ci is designed for CI. It reads package-lock.json and installs exact versions. No surprises, no version drift.
3. Parallel Jobs
Lint, type-check, and test run in parallel. They don’t depend on each other. This cuts total time from ~5 minutes to ~2 minutes.
jobs:
lint: ... # Runs in parallel
type-check: ... # Runs in parallel
test: ... # Runs in parallel
build:
needs: [lint, type-check, test] # Waits for all three
deploy:
needs: build # Waits for build
4. Artifact Passing
The build job uploads the dist/ folder as an artifact. The deploy job downloads it. This ensures the deploy uses the exact same build that was tested.
Adding Notifications
I want to know when the pipeline fails. GitHub sends email by default, but I also use Slack:
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
# ... deploy steps ...
- name: Notify Slack on success
if: success()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ Deployed to production: \${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: \${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "❌ Deploy failed: \${{ github.sha }}
\${{ github.actor }}"
}
env:
SLACK_WEBHOOK_URL: \${{ secrets.SLACK_WEBHOOK_URL }}
Deployment: The Actual Step
For Cloudflare Pages, I use the official action:
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: cloudflare/pages-action@v1
with:
apiToken: \${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-app
directory: dist/
For Vercel, it’s even simpler — just push to main and Vercel deploys automatically.
Preview Deployments for PRs
I also want preview URLs for pull requests:
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# ... lint, test, build ...
preview:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: cloudflare/pages-action@v1
with:
apiToken: \${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-app
directory: dist/
branch: \${{ github.head_ref }}
Now every PR gets a live preview URL. Reviewers can see the changes before merging.
Common Pitfalls
1. Secrets not available in PRs from forks: GitHub doesn’t expose secrets to fork PRs. Use pull_request_target carefully, or require contributors to be collaborators.
2. Caching not working: Make sure the cache key matches. If you change Node versions, the cache is invalidated.
3. Flaky tests: If a test fails 1 in 10 times, your pipeline will be unreliable. Fix flaky tests before automating.
4. Long build times: Use actions/cache for anything expensive. Consider splitting large test suites across multiple runners.
Cost
GitHub Actions gives 2,000 free minutes per month for public repos. My pipeline uses ~5 minutes per run. With 20 pushes per month, that’s 100 minutes — well within the free tier.
Final Pipeline
My complete pipeline takes about 3 minutes from push to deploy. I get a Slack notification when it’s done. If something breaks, I know immediately. And I haven’t SSH’d into a server at 11 PM since.