Free CI/CD with GitHub Actions: Automatically Build, Test, and Deploy to a VPS
Modern teams ship faster because they don’t deploy manually. With CI/CD (Continuous Integration / Continuous Deployment), every change pushed to GitHub can automatically trigger a workflow to build, test, and (optionally) deploy your web application to a VPS—reliably and repeatably.
The best part: for many projects, GitHub Actions can be used for free (especially on public repos and within generous limits for private repos). In this guide, you’ll learn how to set up a professional GitHub Actions pipeline that deploys to a VPS via SSH.
1) What CI/CD Means (In Practical Terms)
CI (Continuous Integration)
Whenever you push code or open a pull request, GitHub Actions can automatically:
- install dependencies
- run lint checks
- run unit/integration tests
- build your app
CI ensures your code stays deployable.
CD (Continuous Deployment/Delivery)
After CI passes, the pipeline can automatically deploy:
- to a staging server (common)
- to production (on merge to
main)
CD reduces human error and makes releases consistent.
2) What You Need Before Setting Up GitHub Actions
To deploy to a VPS, you’ll need:
- A VPS with SSH access (Ubuntu/Debian is common)
- A GitHub repository containing your app
- A production-ready app startup method, such as:
- systemd service
- PM2 (Node.js)
- Docker Compose
- a scripted restart process
- Basic server security:
- SSH key authentication preferred
- firewall allowing SSH (port 22 or custom), plus 80/443 for web traffic
3) Deployment Strategies to a VPS (Choose One)
Before writing YAML, decide how you’ll deploy.
Strategy A: “Pull on server” (Simple and popular)
Workflow:
- GitHub Actions SSH into the VPS
git pulllatest code- install/build on server
- restart the service
Pros: easiest, minimal tooling
Cons: build happens on server; requires build tools installed
Strategy B: “Build in Actions, upload artifact” (More controlled)
Workflow:
- CI builds the project in GitHub Actions
- uploads build output (artifact)
- deploy step copies it to VPS (scp/rsync)
- restart
Pros: consistent builds, server is simpler
Cons: more steps and config
Strategy C: “Docker image deploy” (Most professional for scaling)
Workflow:
- build Docker image in Actions
- push to registry (GHCR/Docker Hub)
- VPS pulls image +
docker compose up -d - rollback by image tag if needed
Pros: reproducible deployments, easy rollback
Cons: requires Docker workflow knowledge
This article focuses on Strategy A (fastest for beginners), while also showing how to adapt to Docker at the end.
4) Step 1 — Prepare Your VPS for Automated Deploys
A) Create a dedicated deploy user (recommended)
On your VPS:
sudo adduser deploy
sudo usermod -aG sudo deploy
Then you’ll SSH as deploy instead of root.
B) Install essentials (example for Ubuntu/Debian)
You’ll typically need:
- Git
- your runtime (Node/Python/PHP)
- build tools (optional, depends on your stack)
Example:
sudo apt update
sudo apt install -y git
For Node apps, also install Node.js (via a stable method like NodeSource or nvm). For Docker-based deployments, install Docker + Docker Compose.
C) Set up your app directory
A common standard:
/var/www/myapp- owned by the deploy user
sudo mkdir -p /var/www/myapp
sudo chown -R deploy:deploy /var/www/myapp
5) Step 2 — Create an SSH Key for GitHub Actions
GitHub Actions needs a private key stored as a GitHub Secret.
A) Generate a deploy key locally (or on your VPS)
On your local machine:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f github_actions_deploy_key
You’ll get:
github_actions_deploy_key(private key) → goes into GitHub Secretsgithub_actions_deploy_key.pub(public key) → goes into VPSauthorized_keys
B) Add the public key to the VPS
On your VPS (as deploy user):
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
Paste the contents of github_actions_deploy_key.pub.
Then:
chmod 600 ~/.ssh/authorized_keys
C) Add secrets in GitHub
In your GitHub repo:
Settings → Secrets and variables → Actions → New repository secret
Add:
VPS_HOST= your server IP or domainVPS_USER=deployVPS_SSH_KEY= contents ofgithub_actions_deploy_key(private key)VPS_PORT=22(or your custom SSH port)
Optional but useful:
APP_DIR=/var/www/myapp
6) Step 3 — Add a Basic CI Workflow (Build + Test)
Create this file in your repo:
.github/workflows/ci.yml
Example for a Node.js app
name: CI
on:
pull_request:
push:
branches: [ "main" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint --if-present
- name: Test
run: npm test --if-present
- name: Build
run: npm run build --if-present
This gives you immediate value: every PR can be validated automatically.
7) Step 4 — Add the Deploy Workflow (SSH into VPS)
Create:
.github/workflows/deploy.yml
This deploy workflow will run only when code is pushed to main (you can change this to tags/releases if you prefer).
Deploy example (pull and restart)
name: Deploy to VPS
on:
push:
branches: [ "main" ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy over 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: |
set -e
cd /var/www/myapp
# If repo not cloned yet, clone it (one-time setup)
if [ ! -d ".git" ]; then
git clone <https://github.com/><YOUR_USERNAME>/<YOUR_REPO>.git .
fi
git fetch --all
git reset --hard origin/main
# Install/build steps (example Node)
npm ci
npm run build --if-present
# Restart your service (choose one)
# 1) systemd:
sudo systemctl restart myapp
# OR 2) PM2:
# pm2 reload ecosystem.config.js --env production
Important notes
Replace
<YOUR_USERNAME>/<YOUR_REPO>with your real repo path.Make sure your VPS has permissions to restart the service.
If you use
sudo systemctl restart myapp, your deploy user must have sudo permission for that command (see next section).
8) Step 5 — Restart the App Correctly (systemd Example)
For production, systemd is a stable way to keep apps running.
Example systemd service (Node)
Create:
/etc/systemd/system/myapp.service
[Unit]
Description=MyApp Node Service
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp
Environment=NODE_ENV=production
ExecStart=/usr/bin/node /var/www/myapp/server.js
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
Allow deploy user to restart service without password
Edit sudoers safely:
sudo visudo
Add:
deploy ALL=NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl status myapp
Now GitHub Actions can restart the service via SSH without hanging on a password prompt.
9) Production Considerations: Secrets, .env, and Configuration
Don’t store production secrets in GitHub Actions scripts
Your repo should not contain real secrets.
On the VPS, keep a production .env file:
/var/www/myapp/.env(not in Git)
Make sure your app loads it safely (for Node: use dotenv in your runtime, or systemd EnvironmentFile).
Example systemd improvement:
EnvironmentFile=/var/www/myapp/.env
Then restart.
10) Add Safety: Deploy Only When CI Passes
A common mistake is deploying even if tests fail. You can chain workflows by putting CI + deploy in one workflow, or make deploy depend on CI.
Simple approach: single workflow with two jobs
One file, .github/workflows/pipeline.yml:
testjob runs firstdeployruns only iftestsucceeds
This prevents broken code from reaching production.
11) Optional: Faster, Cleaner Deploy with Docker Compose
If your VPS uses Docker, your deploy script can be as simple as:
cd /var/www/myapp
git reset --hard origin/main
docker compose up -d --build
Benefits:
- consistent runtime
- easier dependency management
- straightforward rollback (by Git tag or image tags)
12) Common Problems (and Quick Fixes)
“Permission denied (publickey)”
- Make sure the VPS has the public key in
~/.ssh/authorized_keys - Ensure correct file permissions:
~/.ssh= 700authorized_keys= 600
Workflow hangs during restart
Your command is waiting for a sudo password prompt
Fix with NOPASSWD sudoers rule for the restart command.
Deploy works but website still shows old version
- You forgot to restart the process
- You have caching (Nginx, CDN) serving old assets
- Your build output wasn’t updated or your app reads from the wrong folder
Tests pass locally but fail in Actions
- Node/Python version mismatch
- missing environment variables for tests
- OS differences (Linux runner vs local machine)
Conclusion
With GitHub Actions, you can create a free and professional CI/CD pipeline that automatically builds, tests, and deploys your application to a VPS. The key is to keep the pipeline simple and reliable:
- run CI on every PR/push
- deploy only from
main(or tagged releases) - deploy over SSH with a dedicated deploy user
- restart using systemd/PM2/Docker Compose
- store secrets securely (GitHub Secrets + server-side env files)
Once this foundation is in place, you can extend it with staging environments, rollback workflows, Docker registry deployments, and notifications—without changing the core idea: automation makes production safer.

Komentar
Posting Komentar