GitHub Actions Self-Hosted Runner Pipeline Setup
By David Bakare
August 29, 2025
5 min read
GitHub ActionsCI/CDDevOpsDockerLinux

Github Actions
This guide provides a comprehensive, step-by-step walkthrough to set up a CI/CD pipeline using a GitHub Actions self-hosted runner on a Virtual Private Server (VPS). This approach is highly flexible and cost-effective, allowing for full control over your build and deployment environment.
ssh user@VPS_IP
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL [https://get.docker.com](https://get.docker.com) | sh
# Add current user to docker group
sudo usermod -aG docker $USER
# Log out and back in for group changes to take effect
# Or use: newgrp docker
# Verify Docker installation
docker --version
# Install Docker Compose plugin
sudo apt install -y docker-compose
# Verify Docker Compose installation
docker compose version
# Create runner directory
mkdir ~/actions-runner && cd ~/actions-runner
# Download latest runner package
curl -o actions-runner-linux-x64.tar.gz -L \
[https://github.com/actions/runner/releases/latest/download/actions-runner-linux-x64.tar.gz](https://github.com/actions/runner/releases/latest/download/actions-runner-linux-x64.tar.gz)
# Extract the archive
tar xzf ./actions-runner-linux-x64.tar.gz
# Configure the runner (replace with your values)
./config.sh --url [https://github.com/USERNAME/REPO](https://github.com/USERNAME/REPO) --token YOUR_TOKEN
# Test manually
./run.sh
# Once confirmed working, stop with Ctrl+C
# Install as systemd service
sudo ./svc.sh install
# Start the service
sudo ./svc.sh start
# Check service status
sudo ./svc.sh status
Create project directory structure:
mkdir ~/your-project
cd ~/your-project
Create docker-compose.yml:
version: "3.8"
services:
app:
image: your-dockerhub-username/your-app:latest
container_name: your-app
restart: always
ports:
- "80:80"
environment:
- NODE_ENV=production
# Optional: Add volumes for persistent data
# volumes:
# - ./data:/app/data
Multi-stage Dockerfile for Node.js application:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Enable pnpm (if using pnpm)
RUN corepack enable pnpm
# Copy dependency files
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build application
RUN pnpm run build
# Stage 2: Production
FROM nginx:alpine AS runner
# Remove default nginx static files
RUN rm -rf /usr/share/nginx/html/*
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration if needed
# COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Create .github/workflows/deploy.yml:
name: CI/CD Pipeline
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
DOCKER_IMAGE: your-dockerhub-username/your-app
jobs:
build-and-deploy:
runs-on: self-hosted
steps:
- name: Checkout code
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_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.DOCKER_IMAGE }}:latest
${{ env.DOCKER_IMAGE }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Deploy with Docker Compose
run: |
cd ~/your-project
docker compose pull
docker compose up -d --force-recreate
docker system prune -f
- name: Health check
run: |
sleep 10
curl -f http://localhost || exit 1
Add these secrets to your GitHub repository (Settings → Secrets and variables → Actions):
DOCKER_USERNAME: Your Docker Hub usernameDOCKER_PASSWORD: Your Docker Hub password or access tokenIf using Nginx, create nginx.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Check runner status
sudo ./svc.sh status
# Stop runner
sudo ./svc.sh stop
# Start runner
sudo ./svc.sh start
# Uninstall runner service
sudo ./svc.sh uninstall
# Remove unused containers, networks, images
docker system prune -a -f
# Check disk usage
docker system df
# View logs
docker compose logs -f
# Restart services
docker compose restart
Runner not starting
# Check logs
journalctl -u actions.runner.* -f
Permission denied errors
# Ensure user is in docker group
groups $USER
# Re-login if needed
Port already in use
# Find process using port
sudo lsof -i :80
# Stop conflicting service or change port
Docker image pull failures
# Verify Docker Hub credentials
docker login
# Check image name and tag