A CI/CD pipeline automates testing, building, and deploying your application — but many developers assume you need a paid CI service or a second server to run one. In reality, you can set up a complete pipeline using GitHub Actions (free for public repos, 2,000 minutes/month for private repos) that deploys directly to your single VPS. This tutorial shows how to build a GitHub Actions workflow that tests your code, builds a Docker image, deploys it to your VPS via SSH, and runs the new container — all without any additional infrastructure costs.
Prerequisites
- A Linux VPS with Docker and Docker Compose installed
- Your application code in a GitHub repository
- SSH key-based access to your VPS (passwordless)
- A Dockerfile and docker-compose.yml for your application (see our companion guide on containerizing a Node.js app)
If you need a VPS for this setup, compare budget-friendly VPS plans on our table. A 2 GB RAM VPS is sufficient for most small-to-medium applications.
Step 1: Prepare Your VPS for Automated Deployments
First, create a dedicated directory on your VPS to hold the application code and Docker Compose files:
# On your VPS
mkdir -p /opt/myapp
cd /opt/myapp
# Create a directory for the deployment script
mkdir -p /opt/deploy
Create a deployment script that GitHub Actions will call via SSH. This script pulls the latest code, rebuilds the Docker image, and restarts the container:
#!/bin/bash
# /opt/deploy/deploy.sh
set -e
APP_DIR="/opt/myapp"
REPO_URL="https://github.com/your-org/your-repo.git"
BRANCH="main"
echo "[$(date)] Starting deployment..."
# Clone or pull the latest code
if [ -d "$APP_DIR/.git" ]; then
cd "$APP_DIR"
git pull origin "$BRANCH"
else
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
cd "$APP_DIR"
fi
# Build and restart containers
docker compose build
docker compose up -d --no-deps
# Clean up old images
docker image prune -f
echo "[$(date)] Deployment complete."
Make the script executable:
chmod +x /opt/deploy/deploy.sh
Step 2: Generate a Deploy SSH Key
GitHub Actions needs an SSH key to connect to your VPS. Generate a dedicated key pair for deployments:
# On your local machine (not the VPS)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github-actions-deploy -N ""
Add the public key to your VPS’s authorized keys:
# Copy the public key to your VPS
ssh-copy-id -i ~/.ssh/github-actions-deploy.pub user@your-vps-ip
# Or manually append the public key to ~/.ssh/authorized_keys on the VPS
Test the connection:
ssh -i ~/.ssh/github-actions-deploy user@your-vps-ip "echo 'SSH connection works'"
Step 3: Add Repository Secrets to GitHub
Go to your GitHub repository → Settings → Secrets and variables → Actions → New repository secret. Add these secrets:
- VPS_HOST — Your VPS IP address (e.g.,
192.168.1.100) - VPS_USER — The SSH username on your VPS (e.g.,
ubuntuordeploy) - VPS_SSH_KEY — The entire contents of the
github-actions-deployprivate key file (including the-----BEGIN OPENSSH PRIVATE KEY-----header) - VPS_KNOWN_HOSTS — The fingerprint of your VPS host key. Get it by running:
ssh-keyscan -t ed25519 your-vps-ipon your local machine
Step 4: Create the GitHub Actions Workflow
Create a workflow file at .github/workflows/deploy.yml in your repository:
name: Deploy to VPS
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
if: success()
steps:
- uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.VPS_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy to VPS
run: |
ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd /opt/myapp && \
git pull origin main && \
docker compose build && \
docker compose up -d --no-deps && \
docker image prune -f"
- name: Verify deployment
run: |
sleep 10
curl -s -o /dev/null -w "%{http_code}" \
http://${{ secrets.VPS_HOST }}:3000/health
This workflow does two things:
- test job — Runs on every push to main. Checks out code, installs dependencies, runs the linter, and runs tests.
- deploy job — Only runs if tests pass. Connects to the VPS via SSH, pulls the latest code, rebuilds the Docker image, and restarts the container with zero-downtime flags.
Step 5: Push and Test the Pipeline
Commit the workflow file and push to your repository:
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD deployment workflow"
git push origin main
Go to your repository on GitHub → Actions tab. You should see the workflow running. Click into it to watch the test and deploy steps execute in real time.
Step 6: Add a Health Check and Rollback
A production CI/CD pipeline needs rollback capability. Add a Docker Compose label to track versions, and a rollback script:
# In docker-compose.yml
services:
app:
labels:
- "app.version=${GIT_COMMIT:-unknown}"
Create a rollback script on your VPS:
#!/bin/bash
# /opt/deploy/rollback.sh
# Usage: ./rollback.sh [commit-hash]
COMMIT=$1
if [ -z "$COMMIT" ]; then
echo "Usage: $0 "
exit 1
fi
cd /opt/myapp
git checkout "$COMMIT"
docker compose build
docker compose up -d --no-deps
echo "Rolled back to $COMMIT"
Step 7: Secure the Pipeline
Security considerations for VPS-based CI/CD:
- Use a dedicated deploy user — Create a user on your VPS with minimal permissions:
sudo adduser --disabled-password deploy. Only grant it access to/opt/myappand the Docker socket. - Restrict SSH access — Limit the deploy user to running only the deployment script via
authorized_keyscommand restriction:command="/opt/deploy/deploy.sh",no-agent-forwarding,no-port-forwarding,no-pty - Never store secrets in the repository — Use GitHub Secrets for all sensitive values.
- Use a private repository — If your code is proprietary, keep the repository private. GitHub Actions offers 2,000 free minutes per month for private repos.
- Monitor disk usage — Docker images accumulate on the VPS. Add
docker image prune -fto your deployment script to clean up old images.
Troubleshooting Common Issues
- SSH connection refused: Check that your VPS firewall allows SSH (port 22). Verify the VPS is running with
sudo systemctl status ssh. - Permission denied (publickey): The private key in GitHub Secrets may be incorrect or the public key is not in
~/.ssh/authorized_keyson the VPS. - Host key verification failed: The
VPS_KNOWN_HOSTSsecret is incorrect. Re-runssh-keyscan -t ed25519 your-vps-ipand copy the exact output. - Docker command not found: The SSH user on the VPS may not have Docker permissions. Add the user to the
dockergroup:sudo usermod -aG docker deploy. - Tests passing but deploy failing: Check the VPS has enough disk space (
df -h). Docker builds can consume several GB of temporary space.
Going Further: Slack Notifications
Add a Slack notification step to your workflow so your team knows when deployments succeed or fail:
- name: Notify Slack on success
if: success()
uses: slackapi/[email protected]
with:
payload: |
{
"text": "✅ Deployment successful! ${{ github.repository }}@${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify Slack on failure
if: failure()
uses: slackapi/[email protected]
with:
payload: |
{
"text": "❌ Deployment failed! ${{ github.repository }}@${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Setting up CI/CD on a budget VPS with GitHub Actions gives you professional-grade deployment automation without any additional infrastructure costs. The same pipeline works for Node.js, Python, Go, Rust, or any language that runs in Docker. For more VPS deployment guides and provider comparisons, visit the main site.


Leave a Reply
You must be logged in to post a comment.