Remember the old days?
You write code. You test it manually (maybe). You open FileZilla. You drag and drop files to a server. You pray.
Then someone yells: "Who deployed on a Friday?!"
Those days are dead.
CI/CD pipelines are the reason modern developers ship code 10x faster with 90% fewer bugs. And the best part? You can set one up in 10 minutes. For free.
Let me show you.
What is CI/CD? (The 30-second explanation)
CI = Continuous Integration – Every time you push code, automatically test it.
CD = Continuous Delivery/Deployment – If tests pass, automatically deploy it.
In practice:
git push origin main
↓
[Automatic] Run tests
↓
[Automatic] Build the app
↓
[Automatic] Deploy to server
↓
You get a notification: "✅ Deployment successful"
You never touch a server. You never run scp or FTP. You just push code.
The 10-Minute Challenge
By the end of this guide, you will have:
· A simple Node.js/Express app (or your own project)
· A GitHub repository
· A GitHub Actions workflow that:
· Runs tests automatically
· Deploys to a free hosting service (Render.com – no credit card)
Total time: ~10 minutes. I'll wait.
What You'll Need (All Free)
Tool Why
GitHub account Hosts your code + runs CI/CD
Render.com account Free hosting (similar to Heroku)
A simple app We'll make one in 2 minutes
No credit card required. No servers to manage.
Step 1: The App (2 minutes)
Create a new folder and a simple Express app:
mkdir my-cicd-app
cd my-cicd-app
npm init -y
npm install express
Create index.js:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'CI/CD works! 🚀' });
});
app.get('/health', (req, res) => {
res.status(200).send('OK');
});
// A simple test endpoint
app.get('/add/:a/:b', (req, res) => {
const a = parseInt(req.params.a);
const b = parseInt(req.params.b);
res.json({ result: a + b });
});
app.listen(PORT, () => {
console.log(`App running on port ${PORT}`);
});
Create a test file test.js (we'll use Node's built-in assert):
const assert = require('assert');
// Simple tests that will run in CI
console.log('Running tests...');
// Test 1: Addition works
function add(a, b) { return a + b; }
assert.strictEqual(add(2, 3), 5);
console.log('✅ Test 1 passed');
// Test 2: String test
assert.strictEqual(typeof 'hello', 'string');
console.log('✅ Test 2 passed');
console.log('🎉 All tests passed!');
Update package.json with a test script:
{
"scripts": {
"start": "node index.js",
"test": "node test.js"
}
}
Test it locally:
npm test # Should show "All tests passed!"
npm start # Visit http://localhost:3000
Step 2: Push to GitHub (2 minutes)
Create a repository on GitHub (don't initialize with README – we have our own).
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/YOUR_USERNAME/my-cicd-app.git
git branch -M main
git push -u origin main
Step 3: Create the CI/CD Pipeline (3 minutes)
This is where the magic happens. GitHub Actions reads a file in .github/workflows/ and runs it.
Create this folder and file:
mkdir -p .github/workflows
Create .github/workflows/deploy.yml:
name: Deploy to Production
# When should this run?
on:
push:
branches: [ main ] # Every push to main branch
workflow_dispatch: # Also allow manual trigger
# What environment variables do we need?
env:
NODE_VERSION: '18'
# Jobs run in parallel by default
jobs:
# JOB 1: Test
test:
name: Run Tests
runs-on: ubuntu-latest # Free Linux runner
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm ci # ci is faster and stricter than install
- name: Run tests
run: npm test
# JOB 2: Deploy (only if tests passed)
deploy:
name: Deploy to Render
runs-on: ubuntu-latest
needs: test # Wait for test job to succeed
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to Render
uses: johnbeynon/render-deploy-action@v1
with:
service-id: ${{ secrets.RENDER_SERVICE_ID }}
api-key: ${{ secrets.RENDER_API_KEY }}
What this does:
- On every git push to main:
- Spin up a fresh Linux machine (free)
- Run npm test
- If tests pass → deploy to Render
- If tests fail → stop (no deploy)
Step 4: Hosting on Render (3 minutes)
Render is like the old Heroku (but with a free tier that actually works).
- Go to render.com and sign up with GitHub.
- Click "New +" → "Web Service"
- Connect your GitHub repository (my-cicd-app)
- Fill in the settings: · Name: my-cicd-app · Environment: Node · Build Command: npm install · Start Command: npm start · Plan: Free
- Click "Create Web Service"
Render will deploy your app once manually. Wait for the green "Live" badge.
You'll get a URL like: https://my-cicd-app.onrender.com
Visit it. You should see your JSON message.
Step 5: Connect GitHub Actions to Render (2 minutes)
Now we need to give GitHub Actions permission to deploy to Render.
Get your Render API Key:
- Go to Render Dashboard → Account Settings (top right)
- Scroll to "API Keys" → Click "Create API Key"
- Name it github-actions → Copy the key (starts with rnd_)
Get your Render Service ID:
- Go to your Web Service page on Render
- Look at the URL: https://dashboard.render.com/web/srv-abc123def
- The srv-abc123def part is your Service ID
Add them as GitHub Secrets:
- Go to your GitHub repository → Settings → Secrets and variables → Actions
- Click "New repository secret"
- Add RENDER_API_KEY → paste your API key
- Add RENDER_SERVICE_ID → paste your Service ID
Step 6: Trigger Your First Pipeline (1 minute)
Make a small change to trigger the pipeline:
# Change the message in index.js
res.json({ message: 'My first CI/CD pipeline! 🎉' });
# Commit and push
git add .
git commit -m "Test CI/CD pipeline"
git push origin main
Now go to your GitHub repository → Actions tab.
You'll see a workflow running:
Deploy to Production
├── Run Tests (yellow → green)
└── Deploy to Render (yellow → green)
When both turn green ✅, visit your Render URL. Your new message is live.
You just did CI/CD. 🚀
What Just Happened? (The Diagram)
Your laptop
│
│ git push origin main
▼
GitHub (Actions)
│
│ Spins up runner
▼
┌─────────────────────────────────────┐
│ CI Pipeline (Test) │
│ ├── npm ci │
│ ├── npm test │
│ └── ✅ All tests passed │
└─────────────────────────────────────┘
│
│ Tests pass → trigger CD
▼
┌─────────────────────────────────────┐
│ CD Pipeline (Deploy) │
│ ├── Call Render API │
│ ├── Render pulls code │
│ ├── npm install │
│ ├── npm start │
│ └── ✅ Live at your URL │
└─────────────────────────────────────┘
│
▼
Production server (Render)
Common Variations (Adapt to Your Stack)
For a Python (Django/Flask) app:
# .github/workflows/deploy.yml
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run tests
run: pytest
- name: Deploy
run: |
# Render handles Python automatically
# Or use: flyctl deploy, railway up, etc.
For a React/Vite static site:
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v2
with:
publish-dir: './dist'
production-branch: main
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
For a Docker container:
- name: Build Docker image
run: docker build -t myapp .
- name: Push to Docker Hub
run: |
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker push myusername/myapp:latest
- name: Deploy to DigitalOcean
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
script: docker pull myusername/myapp && docker-compose up -d
Pro Tips (From Someone Who Broke Production)
- Always test before deploying (duh)
The needs: test line ensures you never deploy broken code. Add a failing test and watch the pipeline stop.
// Add this to test.js – it will FAIL
assert.strictEqual(1, 2);
console.log('This line never runs');
Push it. Watch the pipeline fail at the test stage. Deploy never happens. You just saved production.
- Use npm ci instead of npm install
· npm install can update package-lock.json (unexpected changes)
· npm ci installs exactly what's in package-lock.json (reproducible builds)
Always use npm ci in CI.
- Add a "staging" environment
For real projects, deploy to staging first:
on:
push:
branches: [ main ] # Deploys to staging
branches: [ production ] # Deploys to production
- Add notifications
Get a Slack/Discord message when deploy finishes:
- name: Send Slack notification
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ Deployed to production!"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- Manual approval for production (for serious teams)
deploy-prod:
needs: test
environment: production # Requires approval in GitHub
runs-on: ubuntu-latest
steps:
- name: Deploy to production
run: ./deploy.sh
Then go to GitHub → Settings → Environments → Add protection rule.
Troubleshooting (When Things Go Wrong)
Symptom Most likely fix
Workflow doesn't trigger Check branch name: main vs master
Tests fail but work locally Different Node version? Add .nvmrc file
Deploy step fails Wrong Render Service ID or API key
"Module not found" Forgot npm ci step?
"Permission denied" GitHub Actions needs write permissions → Settings → Actions → General → Workflow permissions
What You Just Learned
✅ CI = Automatic testing on every push
✅ CD = Automatic deployment if tests pass
✅ GitHub Actions = Free CI/CD runners
✅ Render = Free hosting with API deploys
✅ Secrets = Store API keys safely
You can now add CI/CD to any project in ~10 minutes.
Next Steps (Level Up)
- Add more tests – Unit tests, integration tests
- Add a database – Render has free PostgreSQL
- Add a staging environment – Deploy to staging first, then production
- Add end-to-end tests – Cypress or Playwright
- Try other platforms – Vercel, Netlify, Railway, Fly.io
The Ultimate Test
Break something on purpose:
// Add this to index.js – a hidden bug
app.get('/broken', (req, res) => {
throw new Error('Oops'); // This will crash the server
});
Push it. Watch the pipeline deploy it (tests passed, right?). Then visit /broken and watch it fail.
Now fix it. Push again. Watch the pipeline fix production automatically.
That's the power of CI/CD. You're not afraid to deploy anymore. Because if something breaks, you fix it and push again. No panic. No Friday deploys. Just code.
Your 10-Minute Challenge Recap
· Created an Express app
· Pushed to GitHub
· Created .github/workflows/deploy.yml
· Signed up for Render
· Added API keys as GitHub Secrets
· Made a change and watched the pipeline run
You did it. 🎉
Now go add CI/CD to your real project. Your future self (and your team) will thank you.
What stack do you want to see a CI/CD example for next? Drop a comment (Django, Spring Boot, Next.js, Go, Rust...).
Follow for more DevOps-for-beginners content.
Top comments (0)