Remote Work Tools

A solo developer CI/CD pipeline does one thing: make sure you never manually deploy again. Every push to main runs your tests, builds your artifact, and ships it. If anything breaks, the deploy stops.

This guide builds a full pipeline using GitHub Actions: test on every PR, build a Docker image on merge to main, push to a registry, and deploy to a VPS via SSH. The same pattern works for static sites, Node apps, Python services, or Go binaries.

What the Pipeline Does

push to PR branch
  → lint + test (fails fast)
  → PR passes checks

merge to main
  → lint + test
  → Docker build + push to GHCR
  → SSH into VPS, pull image, restart container

No manual steps. No “did I run tests?” anxiety.

Repository Structure

myapp/
├── .github/
│   └── workflows/
│       ├── ci.yml          # runs on every push + PR
│       └── deploy.yml      # runs on merge to main
├── Dockerfile
├── docker-compose.prod.yml
├── src/
└── tests/

CI Workflow: Test Every Push

# .github/workflows/ci.yml
name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Run tests
        run: npm test -- --coverage

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/
          retention-days: 7

This workflow runs on every push and every PR. If tests fail on a PR, the merge button is blocked. Branch protection rules enforce this — enable them under Settings → Branches → main → Require status checks.

Deploy Workflow: Ship on Merge to Main

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

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

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - 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.GITHUB_TOKEN }}

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

      - 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

      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            echo ${{ secrets.GHCR_TOKEN }} | docker login ghcr.io -u ${{ secrets.GHCR_USER }} --password-stdin
            docker pull ghcr.io/${{ github.repository }}:latest
            docker compose -f /opt/myapp/docker-compose.prod.yml up -d --no-deps app
            docker image prune -f

Dockerfile for the App

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
EXPOSE 3000
USER node
CMD ["node", "src/index.js"]

Multi-stage build keeps the image lean. The builder stage installs deps; the runtime stage copies only what runs in production.

Production Docker Compose on the VPS

# /opt/myapp/docker-compose.prod.yml
version: "3.9"

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

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - app

The app binds only to 127.0.0.1:3000 — nginx is the public-facing proxy. This prevents direct container access from the internet.

GitHub Secrets to Configure

Set these in Settings → Secrets and variables → Actions:

Secret Value
VPS_HOST IP or domain of your VPS
VPS_USER SSH user (e.g. deploy)
VPS_SSH_KEY Private key (the full PEM, including headers)
GHCR_USER Your GitHub username
GHCR_TOKEN A PAT with read:packages scope

GITHUB_TOKEN is automatic — no configuration needed.

Setting Up the Deploy User on the VPS

# On your VPS — create a deploy user with minimal permissions
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy

# Add the GitHub Actions public key
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh

# Paste the public key (matching VPS_SSH_KEY secret)
sudo nano /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh

# Give deploy user access to the app directory
sudo mkdir -p /opt/myapp
sudo chown deploy:deploy /opt/myapp

The deploy user has Docker access but no sudo. It can only pull images and restart containers.

Adding a Database Migration Step

If your app uses database migrations, run them before the container swap:

# Add this step before "Deploy to VPS" in deploy.yml
- name: Run migrations
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.VPS_HOST }}
    username: ${{ secrets.VPS_USER }}
    key: ${{ secrets.VPS_SSH_KEY }}
    script: |
      docker run --rm \
        --network host \
        --env-file /opt/myapp/.env.prod \
        ghcr.io/${{ github.repository }}:latest \
        node src/migrate.js

Migrations run in a one-off container using the same image before the service restarts.

Caching Dependencies to Speed Up Builds

Docker layer caching via cache-from: type=gha reuses layers between runs. For npm specifically, the actions/setup-node cache key hashes package-lock.json — unchanged lock file means instant dep install.

For Python projects, replace the Node setup step:

- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: "pip"
    cache-dependency-path: requirements.txt

Notifications on Failure

# Add to the end of deploy.yml
- name: Notify on failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "Deploy failed for ${{ github.repository }} on commit ${{ github.sha }}. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>"
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

This sends a Slack DM or channel message only when a deploy fails. No noise on success.

Branch Protection Rules

Enable in Settings → Branches → Add rule for main:

With these rules, main is always green. A broken test cannot reach production.

Built by theluckystrike — More at zovo.one