How to Set Up a CI/CD Pipeline on a VPS with GitHub Actions and Docke

How to Set Up a CI/CD Pipeline on a VPS with GitHub Actions and Docke

Manual deployments are one of the most common sources of production incidents. You pull code, run migrations, restart services — and somewhere in that process, something goes wrong. A CI/CD (Continuous Integration/Continuous Deployment) pipeline automates all of this: every push to your repository triggers automated testing, Docker image building, and deployment to your VPS — reliably, repeatably, and without human error.

This guide builds a complete CI/CD pipeline from scratch using GitHub Actions for automation and Docker for containerization, deploying to an Ubuntu VPS. No paid CI platforms required.

What You’ll Build

  • A GitHub Actions workflow that triggers on every push to main
  • Automated tests that run before deployment
  • Docker image build and push to GitHub Container Registry (GHCR)
  • Automatic SSH deployment to your VPS
  • Zero-downtime container updates using Docker Compose
  • Rollback capability if deployment fails

Architecture Overview

Step Where it runs What happens
1. Push to GitHub Your machine Trigger pipeline
2. Run tests GitHub Actions runner Unit/integration tests
3. Build Docker image GitHub Actions runner Build and tag image
4. Push image to GHCR GitHub Actions runner Store in container registry
5. SSH to VPS GitHub Actions runner Pull new image, restart container
6. Health check GitHub Actions runner Verify deployment succeeded

💡 VPS.DO Tip: All VPS.DO KVM VPS plans support Docker and have full root access — everything needed for this CI/CD setup. View USA VPS Plans →


Prerequisites

  • A GitHub repository with your application code
  • Ubuntu VPS 22.04 or 24.04 with Docker installed
  • A non-root deploy user on your VPS
  • Basic familiarity with Docker and GitHub

Step 1: Prepare Your VPS for Automated Deployments

Create a dedicated deploy user

sudo useradd -m -s /bin/bash deploy
sudo usermod -aG docker deploy

Generate an SSH key pair for GitHub Actions

# On your local machine
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy -N ""

# Copy public key to VPS
ssh-copy-id -i ~/.ssh/github_deploy.pub deploy@YOUR_VPS_IP

Test the connection

ssh -i ~/.ssh/github_deploy deploy@YOUR_VPS_IP

Create the deployment directory

sudo mkdir -p /var/deployments/myapp
sudo chown deploy:deploy /var/deployments/myapp

Step 2: Add Secrets to GitHub Repository

Go to your GitHub repository → Settings → Secrets and variables → Actions → New repository secret. Add:

Secret Name Value
VPS_HOST Your VPS IP address
VPS_USER deploy
VPS_SSH_KEY Contents of ~/.ssh/github_deploy (private key)
VPS_PORT Your SSH port (e.g., 2222)
GHCR_TOKEN GitHub Personal Access Token with write:packages scope

To create a GitHub token: GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate with write:packages and read:packages scopes.


Step 3: Create Your Dockerfile

In your repository root, create a production-ready Dockerfile. This example uses a Node.js app:

nano Dockerfile
# ── Build stage ──────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# ── Production stage ─────────────────────────────────
FROM node:22-alpine AS production
WORKDIR /app

# Security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy only production dependencies
COPY --from=builder /app/node_modules ./node_modules
COPY . .

# Set ownership
RUN chown -R appuser:appgroup /app
USER appuser

EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "index.js"]

Create a Docker Compose file on your VPS

nano /var/deployments/myapp/docker-compose.yml
version: '3.8'

services:
  app:
    image: ghcr.io/YOUR_GITHUB_USERNAME/myapp:latest
    container_name: myapp
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    env_file:
      - .env
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

Step 4: Create the GitHub Actions Workflow

In your repository, create the workflow file:

mkdir -p .github/workflows
nano .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

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

jobs:
  # ── Job 1: Run Tests ─────────────────────────────────
  test:
    name: Run Tests
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run linter
        run: npm run lint --if-present

  # ── Job 2: Build and Push Docker Image ───────────────
  build:
    name: Build and Push Image
    runs-on: ubuntu-latest
    needs: test  # Only runs if tests pass
    if: github.ref == 'refs/heads/main'  # Only on main branch

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ── Job 3: Deploy to VPS ─────────────────────────────
  deploy:
    name: Deploy to VPS
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            # Login to GitHub Container Registry
            echo ${{ secrets.GHCR_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin

            # Navigate to deployment directory
            cd /var/deployments/myapp

            # Pull the latest image
            docker pull ghcr.io/${{ github.repository }}:latest

            # Zero-downtime update with Docker Compose
            docker compose pull
            docker compose up -d --no-deps --remove-orphans

            # Wait for health check
            sleep 15

            # Verify container is running and healthy
            if docker compose ps | grep -q "healthy\|running"; then
              echo "✅ Deployment successful"
              # Clean up old images
              docker image prune -f
            else
              echo "❌ Deployment failed — rolling back"
              docker compose restart
              exit 1
            fi

      - name: Notify deployment status
        if: always()
        run: |
          if [ "${{ job.status }}" == "success" ]; then
            echo "✅ Deployment to VPS succeeded"
          else
            echo "❌ Deployment failed"
          fi

Step 5: Add a Health Check Endpoint to Your App

The pipeline checks /health to verify deployment succeeded. Add it to your app:

// In your Node.js app (index.js or routes)
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'healthy',
    uptime: process.uptime(),
    timestamp: new Date().toISOString()
  });
});

Step 6: Test the Pipeline

# Make a change to your code
echo "# Updated" >> README.md
git add .
git commit -m "Test CI/CD pipeline"
git push origin main

Go to your GitHub repository → Actions tab. You’ll see the pipeline running through three stages: Test → Build → Deploy. Each step shows real-time logs. ✅


Step 7: Set Up Environment Variables on VPS

Your Docker container needs production environment variables. Create an .env file on the VPS (never commit this to Git):

nano /var/deployments/myapp/.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
API_KEY=your-production-api-key
JWT_SECRET=your-jwt-secret
chmod 600 /var/deployments/myapp/.env

Step 8: Add Nginx as Reverse Proxy

sudo nano /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1: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;
    }
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d yourdomain.com

Advanced: Branch-Based Deployment Strategy

Deploy different branches to different environments:

on:
  push:
    branches:
      - main        # → Production VPS
      - staging     # → Staging VPS
      - develop     # → Dev VPS (tests only, no deploy)
deploy:
  environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
  steps:
    - name: Deploy
      env:
        VPS_HOST: ${{ github.ref == 'refs/heads/main' && secrets.PROD_VPS_HOST || secrets.STAGING_VPS_HOST }}
      # ... rest of deploy step

Troubleshooting Common Pipeline Issues

SSH connection refused

Verify the SSH key is correctly stored in GitHub Secrets (the full private key including header/footer lines), and confirm the VPS SSH port matches VPS_PORT secret.

Docker permission denied on VPS

sudo usermod -aG docker deploy
# Log out and back in for the group change to take effect

Image pull fails on VPS

Ensure the deploy user logs in to GHCR before pulling. The docker login command in the deploy script handles this.

Tests fail in CI but pass locally

Environment differences cause most CI-specific test failures. Add debug output to the workflow:

- name: Debug environment
  run: |
    node --version
    npm --version
    cat .env.test 2>/dev/null || echo "No .env.test found"

Final Thoughts

A GitHub Actions + Docker CI/CD pipeline on a VPS delivers professional-grade automation at zero additional cost beyond your VPS subscription. Every push to main is automatically tested, containerized, and deployed — with health checks and rollback built in. No Jenkins server to maintain, no paid CI platform required.

VPS.DO’s KVM VPS plans are Docker-ready and ideal as deployment targets for this pipeline. The 1 Gbps network port means image pulls are fast, and the SSD storage handles container operations efficiently.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!