// Automate everything from code to production.
// WHAT WE'RE BUILDING
In this tutorial, you'll build a complete CI/CD pipeline using GitHub Actions. Every time you push code, your pipeline will automatically run tests, build Docker images, and deploy to your server. No more manual deployments - just push and watch your code go live.
// WHY THIS MATTERS
Manual deployments are error-prone and time-consuming. With CI/CD, you get: faster releases, fewer bugs in production, automatic testing, and the confidence to deploy frequently. Every serious development team uses some form of CI/CD.
This project teaches you about:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Git │────▶│ Build │────▶│ Test │────▶│ Deploy │
│ Push │ │ Image │ │ Suite │ │ to VPS │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
│ Dockerfile Run Tests SSH Pull
│ & Build & Lint & Restart
GitHub Actions is free for public repositories and has generous free tiers for private repos. It's deeply integrated with GitHub, meaning you don't need a separate CI server. Everything lives in your repository.
Prerequisites
Before starting this tutorial, make sure you've completed:
If you haven't completed these, check out our other tutorials first!
CI (Continuous Integration) means every time you push code, it's automatically tested and validated. Developers might push dozens of times a day, and each push triggers a build that runs tests.
CD (Continuous Deployment) takes it further - after tests pass, the code is automatically deployed to an environment (staging or production).
First, let's create a simple web application to deploy. We'll use a basic Node.js app with Docker.
$ mkdir -p ~/projects/cicd-demo $ cd ~/projects/cicd-demo $ git init $ git config user.name "Your Name" $ git config user.email "you@example.com"
$ cat > package.json << 'EOF' { "name": "cicd-demo", "version": "1.0.0", "description": "CI/CD Demo Application", "main": "server.js", "scripts": { "start": "node server.js", "test": "jest --coverage" }, "dependencies": { "express": "^4.18.2" }, "devDependencies": { "jest": "^29.7.0" } } EOF
$ cat > server.js << 'EOF' const express = require('express'); const app = express(); const PORT = process.env.PORT || 3000; app.get('/', (req, res) => { res.json({ message: 'Hello from CI/CD Pipeline!', timestamp: new Date().toISOString(), version: '1.0.0' }); }); app.get('/health', (req, res) => { res.json({ status: 'healthy' }); }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); EOF
$ mkdir -p tests $ cat > tests/app.test.js << 'EOF' const app = require('../server'); describe('App tests', () => { test('health endpoint returns healthy', async () => { const response = await request(app).get('/health'); expect(response.status).toBe(200); expect(response.body.status).toBe('healthy'); }); test('root endpoint returns greeting', async () => { const response = await request(app).get('/'); expect(response.status).toBe(200); expect(response.body.message).toBeDefined(); }); }); EOF
$ cat > Dockerfile << 'EOF' FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY server.js ./ EXPOSE 3000 CMD ["npm", "start"] EOF
$ cat > .dockerignore << 'EOF' node_modules npm-debug.log .git .gitignore README.md tests *.test.js jest.config.js EOF
$ cat > .gitignore << 'EOF' node_modules/ .env *.log .DS_Store coverage/ EOF
Now let's create the workflow that will run our CI/CD pipeline.
$ mkdir -p .github/workflows
$ cat > .github/workflows/ci.yml << 'EOF' name: CI Pipeline on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test: name: Test Application runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test -- --passive - name: Upload coverage uses: codecov/codecov-action@v3 with: file: ./coverage/lcov.info build: name: Build Docker Image runs-on: ubuntu-latest needs: test steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: yourusername/cicd-demo tags: | type=ref,event=branch type=sha,prefix= type=raw,value=latest,enable - name: Build and push uses: docker/build-push-action@v5 with: context: . push: ${{ github.ref == 'refs/heads/main' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} EOF
Understanding the Workflow
on: push - Trigger on push to main or develop branchespull_request - Also run on PRs to mainneeds: test - Build job waits for test job to completesecrets.DOCKER_USERNAME - Accesses stored secretsNow let's add a deployment workflow that deploys to our VPS when code is pushed to main.
$ cat > .github/workflows/deploy.yml << 'EOF' name: Deploy to Production on: push: branches: [ main ] workflow_dispatch: jobs: deploy: name: Deploy to VPS runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup SSH uses: webfactory/ssh-agent@v0.8.0 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - name: Deploy to server env: HOST: ${{ secrets.SERVER_HOST }} USER: ${{ secrets.SERVER_USER }} run: | echo "Connecting to $USER@$HOST" ssh -o StrictHostKeyChecking=no $USER@$HOST << 'REMOTE' # Pull latest image docker pull yourusername/cicd-demo:latest # Stop old container docker stop cicd-demo || true docker rm cicd-demo || true # Start new container docker run -d \ --name cicd-demo \ -p 3000:3000 \ --restart unless-stopped \ yourusername/cicd-demo:latest # Show logs docker logs cicd-demo --tail 20 echo "Deployment complete!" REMOTE - name: Verify deployment run: | sleep 5 curl -f https://yourdomain.com/health || exit 1 echo "Deployment verified!" EOF
You can also use community actions for cleaner deployment:
$ cat > .github/workflows/deploy-action.yml << 'EOF' name: Deploy to Production on: push: branches: [ main ] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to Server uses: appleboy/ssh-action@v0.1.7 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/cicd-demo docker pull yourusername/cicd-demo:latest docker stop demo || true docker rm demo || true docker run -d \ --name demo \ -p 3000:3000 \ --restart always \ yourusername/cicd-demo:latest docker image prune -f EOF
Prepare your VPS to receive deployments.
your-username@vps:~$ sudo apt update && sudo apt install -y apt-transport-https ca-certificates curl gnupg lsb-release
your-username@vps:~$ curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
your-username@vps:~$ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
your-username@vps:~$ sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
your-username@vps:~$ sudo mkdir -p /opt/cicd-demo your-username@vps:~$ sudo chown $USER:$USER /opt/cicd-demo
your-username@vps:~$ sudo ufw allow 22/tcp your-username@vps:~$ sudo ufw allow 3000/tcp your-username@vps:~$ sudo ufw enable
your-username@vps:~$ sudo apt install -y nginx
your-username@vps:~$ sudo cat > /etc/nginx/sites-available/cicd-demo << 'EOF' server { listen 80; server_name yourdomain.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_cache_bypass $http_upgrade; } } EOF
your-username@vps:~$ sudo ln -s /etc/nginx/sites-available/cicd-demo /etc/nginx/sites-enabled/ your-username@vps:~$ sudo nginx -t your-username@vps:~$ sudo systemctl reload nginx
Secrets store sensitive data securely in GitHub.
$ ssh-keygen -t ed25519 -C "github-actions" Generating public/private ed25519 key pair. Enter file in which to save the key (/home/user/.ssh/id_ed25519): /home/user/.ssh/github_actions Enter passphrase (empty for no passphrase): Enter same passphrase again:
$ cat ~/.ssh/github_actions.pub | ssh your-username@your-vps-ip "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
Go to your repository on GitHub:
# SSH_PRIVATE_KEY # Copy the PRIVATE key content (not .pub) $ cat ~/.ssh/github_actions # Copy entire output including -----BEGIN OPENSSH PRIVATE KEY----- # SERVER_HOST # Your VPS IP address or domain # Example: 123.45.67.89 or yourdomain.com # SERVER_USER # Your SSH username # Example: your-username # DOCKER_USERNAME # Your Docker Hub username # DOCKER_TOKEN # Docker Hub access token (not password)
Security Note
Never commit secrets to your repository. Use GitHub Secrets for everything sensitive. SSH keys should have a passphrase for additional security. The private key you add to GitHub should be the one without a passphrase for automation, or use ssh-agent with a passphrase.
Let's push our code and watch the magic happen.
$ git add . $ git commit -m "Initial commit with CI/CD pipeline" $ git remote add origin git@github.com:yourusername/cicd-demo.git $ git push -u origin main
# You'll see output like this in the Actions tab: CI Pipeline ✓ Test Application - 1m 23s ✓ Build Docker Image - 2m 45s
Once deployed, verify it's working:
$ curl https://yourdomain.com/health {"status":"healthy"}
$ curl https://yourdomain.com/ {"message":"Hello from CI/CD Pipeline!","timestamp":"2024-01-15T10:30:00.000Z","version":"1.0.0"}
Pass environment variables to your container:
$ cat > .github/workflows/deploy.yml << 'EOF' # ... inside the deploy job: - name: Deploy to Server uses: appleboy/ssh-action@v0.1.7 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} envs: NODE_ENV,LOG_LEVEL script: | docker stop demo || true docker rm demo || true docker run -d \ --name demo \ -p 3000:3000 \ -e NODE_ENV=production \ -e LOG_LEVEL=info \ --restart always \ yourusername/cicd-demo:latest EOF
Deploy to staging first, then production:
$ cat > .github/workflows/multi-env.yml << 'EOF' name: Deploy to Multiple Environments on: push: branches: [ develop ] jobs: deploy-staging: runs-on: ubuntu-latest environment: staging steps: - uses: actions/checkout@v4 - name: Deploy to Staging uses: appleboy/ssh-action@v0.1.7 with: host: ${{ secrets.STAGING_HOST }} username: ${{ secrets.STAGING_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/cicd-demo docker pull yourusername/cicd-demo:staging docker stop demo || true docker run -d --name demo -p 3000:3000 yourusername/cicd-demo:staging deploy-production: needs: deploy-staging runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - name: Deploy to Production uses: appleboy/ssh-action@v0.1.7 with: host: ${{ secrets.PROD_HOST }} username: ${{ secrets.PROD_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | docker pull yourusername/cicd-demo:latest docker stop demo || true docker run -d --name demo -p 3000:3000 yourusername/cicd-demo:latest EOF
Run jobs on a schedule:
cat > .github/workflows/scheduled.yml << 'EOF'
name: Nightly Maintenance
on:
schedule:
- cron: '0 2 * * *' # 2 AM UTC every day
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Clean up old images
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker image prune -af --filter "until=72h"
docker system prune -af
EOF
For more complex applications, use docker-compose:
$ cat > docker-compose.yml << 'EOF' version: '3.8' services: app: image: yourusername/cicd-demo:latest restart: unless-stopped ports: - "3000:3000" environment: - NODE_ENV=production - PORT=3000 nginx: image: nginx:alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - app EOF
cat > deploy.sh << 'EOF'
#!/bin/bash
docker-compose pull
docker-compose up -d
docker image prune -f
EOF
chmod +x deploy.sh
docker logs cicd-demoDebug Tip
Add continue-on-error: true to steps to see more output. You can also add run: echo "${{ secrets.SECRET_NAME }}" to verify secrets are being passed correctly.
You've built a complete CI/CD pipeline!
What You Can Do Now
Every professional development team uses CI/CD. You now have a skill that's in high demand.
The revolution will not be proprietary.