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.