Building Tirraflow Part 4: CI/CD Auto-Deploy with GitHub Actions

Every time I wanted to deploy a change to Tirraflow, I'd SSH into the VPS, pull the latest code, rebuild containers, and restart services. Three repos, same ritual every time: ssh deploy@my-server cd...

Every time I wanted to deploy a change to Tirraflow, I'd SSH into the VPS, pull the latest code, rebuild containers, and restart services. Three repos, same ritual every time:

ssh deploy@my-server
cd ~/app/frontend && git pull
cd ~/app/deploy && docker compose -f docker-compose.prod.yml build frontend
docker compose -f docker-compose.prod.yml up -d frontend

It worked. But it was tedious, error-prone, and easy to forget a step. So I automated it — GitHub Actions now SSHs into the VPS and does the exact same thing, triggered by a merge to main.


The Setup

Tirraflow is split across three GitHub repositories:

Repo

Contains

Deploy action

frontend

React SPA (Vite + Tailwind)

Rebuild frontend container

api

Go/Gin API

Rebuild API container

deploy

docker-compose, Nginx configs, Keycloak themes

Restart affected services

Each repo already had CI pipelines — lint, tests, builds. The goal was to add a deploy job at the end that only runs when code lands on main.

The three repositories, each with its own CI pipeline, and the deploy job added at the end

The Approach: SSH from GitHub Actions

I considered a few options:

SSH won because it's the simplest thing that works. The VPS already accepts SSH. GitHub Actions already runs after tests pass. I just needed to connect the two.

GitHub Actions connecting to the VPS over SSH to pull, rebuild and restart a service

The tool for this is appleboy/ssh-action — a GitHub Action that SSHs into a remote server and runs a script. Battle-tested, widely used, minimal configuration.


Setting Up SSH Access

GitHub Actions needs to authenticate with the VPS. The flow:

  1. Generate an SSH key pair on the VPS (as the deploy user):

    ssh-keygen -t rsa -b 4096
  2. Authorize the public key so the server accepts connections:

    cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
  3. Add the private key to GitHub Secrets — this is what GitHub Actions uses to prove its identity.

Four secrets, configured in each repository (or at the org level to share them):

Secret

Value

VPS_HOST

Server IP address

VPS_USER

Deploy username

VPS_SSH_KEY

Contents of the private key

VPS_PORT

SSH port (usually 22)

Tip: If you have multiple repos under the same GitHub org, set these as organization secrets. Configure once, available everywhere.


Frontend Pipeline

The frontend CI already runs lint, unit tests, E2E tests (Playwright), and a production build. The deploy job chains after all of that:

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

on:
  push:
    branches: [main]
    paths-ignore:
      - '*.md'
      - 'docs/**'
      - 'LICENSE'
      - '.gitignore'
  pull_request:
    branches: [main]
    paths-ignore:
      - '*.md'
      - 'docs/**'
      - 'LICENSE'
      - '.gitignore'

jobs:
  ci:
    name: QA & Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run test:run
      - run: npx playwright install chromium --with-deps
      - run: npm run test:e2e
      - run: npm run build

  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: ci
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd ~/app/frontend && git pull origin main
            cd ~/app/deploy && docker compose -f docker-compose.prod.yml build frontend
            docker compose -f docker-compose.prod.yml up -d frontend

Key details:

The deploy script does exactly what I used to do manually: pull the latest code, rebuild the Docker image (which runs npm run build inside a multi-stage Dockerfile), and restart the container.


API Pipeline

The API pipeline is similar but has an extra step — it builds and publishes a Docker image to GitHub Container Registry (ghcr.io) before deploying:

jobs:
  qa:
    name: QA & Tests
    # ... lint, vet, unit tests, integration tests, build

  docker:
    name: Docker Build & Publish
    needs: qa
    # ... build image, push to ghcr.io, sign with cosign

  deploy:
    name: Deploy to Production
    needs: docker
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd ~/app/api && git pull origin main
            cd ~/app/deploy && docker compose -f docker-compose.prod.yml build api
            docker compose -f docker-compose.prod.yml up -d api

The chain is: QA passes → Docker image published → Deploy to VPS. Three gates before anything touches production.

The API CI also runs on tags (v*.*.*) and a daily schedule for regression detection. The deploy job's if condition ensures it only triggers on main branch pushes — tagged releases and scheduled runs skip deployment.


Deploy Repo: Selective Restarts

The deploy repo is different. It contains infrastructure config — Nginx rules, docker-compose, Keycloak themes. Not every change needs a restart, and restarting the wrong service for no reason is wasteful.

So instead of always restarting everything, the workflow detects which files changed and only restarts affected services:

Deploy workflow detecting which paths changed and restarting only the affected services
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  detect-changes:
    name: Detect Changes
    runs-on: ubuntu-latest
    outputs:
      nginx: ${{ steps.changes.outputs.nginx }}
      keycloak: ${{ steps.changes.outputs.keycloak }}
      compose: ${{ steps.changes.outputs.compose }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Detect changed paths
        id: changes
        run: |
          CHANGED=$(git diff --name-only HEAD~1 HEAD)
          echo "nginx=false" >> "$GITHUB_OUTPUT"
          echo "keycloak=false" >> "$GITHUB_OUTPUT"
          echo "compose=false" >> "$GITHUB_OUTPUT"

          if echo "$CHANGED" | grep -q '^nginx/'; then
            echo "nginx=true" >> "$GITHUB_OUTPUT"
          fi
          if echo "$CHANGED" | grep -q '^keycloak/'; then
            echo "keycloak=true" >> "$GITHUB_OUTPUT"
          fi
          if echo "$CHANGED" | grep -q '^docker-compose.prod.yml'; then
            echo "compose=true" >> "$GITHUB_OUTPUT"
          fi

          echo "Changed files:"
          echo "$CHANGED"

  deploy:
    name: Deploy to Production
    needs: detect-changes
    if: >-
      needs.detect-changes.outputs.nginx == 'true' ||
      needs.detect-changes.outputs.keycloak == 'true' ||
      needs.detect-changes.outputs.compose == 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy config changes
        uses: appleboy/ssh-action@v1
        env:
          NGINX_CHANGED: ${{ needs.detect-changes.outputs.nginx }}
          KEYCLOAK_CHANGED: ${{ needs.detect-changes.outputs.keycloak }}
          COMPOSE_CHANGED: ${{ needs.detect-changes.outputs.compose }}
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          envs: NGINX_CHANGED,KEYCLOAK_CHANGED,COMPOSE_CHANGED
          script: |
            cd ~/app/deploy && git pull origin main

            if [ "$COMPOSE_CHANGED" = "true" ]; then
              echo "docker-compose.prod.yml changed - recreating all services..."
              docker compose -f docker-compose.prod.yml up -d
            else
              if [ "$NGINX_CHANGED" = "true" ]; then
                echo "Restarting nginx..."
                docker compose -f docker-compose.prod.yml restart nginx
              fi
              if [ "$KEYCLOAK_CHANGED" = "true" ]; then
                echo "Restarting keycloak..."
                docker compose -f docker-compose.prod.yml restart keycloak
              fi
            fi

The logic:

Changed files

What happens

nginx/ configs

Restart only Nginx

keycloak/ themes

Restart only Keycloak

docker-compose.prod.yml

Recreate all services

README.md, articles/, etc.

Nothing deploy is skipped entirely

Multiple changes in one push work too. If you update both Nginx config and a Keycloak theme, both services restart. If you change the compose file, everything gets recreated regardless.

The fetch-depth: 2 in the checkout step is important — without it, GitHub Actions does a shallow clone with only the latest commit, and git diff HEAD~1 HEAD would fail.


Skipping Deploys for Docs-Only Changes

Both the frontend and API workflows use paths-ignore to skip the entire pipeline — CI included — when only documentation files change:

on:
  push:
    branches: [main]
    paths-ignore:
      - '*.md'
      - 'docs/**'
      - 'LICENSE'
      - '.gitignore'

This means updating a README doesn't trigger lint, tests, builds, or deployment. It saves CI minutes and avoids unnecessary container rebuilds.

The deploy repo handles this differently through the change detection job because its "docs" (articles, README) live alongside its config files. paths-ignore would be too coarse.


What the Flow Looks Like

Here's the full picture for a typical frontend change:

The pipeline from a push to main: a docs-only change is skipped, otherwise CI runs lint, unit tests, E2E tests and the build, and only on success does the deploy job SSH into the VPS and rebuild the frontend

From push to production: about 3-4 minutes. Most of that is CI — the deploy itself takes under 30 seconds.


Gotchas

1. Secrets must exist in every repo (or at org level).

The first time I set this up, I configured secrets in one repo and forgot the other. The deploy job failed with error: missing server host not the most obvious error message for "your secret doesn't exist."

2. Directory names on the VPS matter.

My workflow scripts referenced ~/app/infra but the actual directory was ~/app/deploy. The SSH command succeeded (it connected fine) but the script failed with cd: No such file or directory. Double-check your paths match what's actually on the server.

3. The deploy user needs Docker permissions.

The deploy user must be in the docker group to run docker compose without sudo:

sudo usermod -aG docker <deploy-user>

4. git pull can fail if there are local changes.

If someone ever edits a file directly on the VPS (a quick hotfix, a config tweak), git pull will fail with merge conflicts. The deploy script assumes the VPS repos are always clean. Discipline matters — all changes go through Git.

5. Firewall rules for GitHub Actions.

If your VPS firewall restricts SSH to specific IPs, you'll need to allow GitHub Actions runners. Their IP ranges are published at the GitHub meta API under the actions key, but they change frequently. An alternative is to use a VPN or bastion host.


The Result

The whole setup took about an hour. The time saved on the first week alone made it worth it.


Update: What the Pipeline Does Now

The workflows above are the first version: pull, build, restart. Six months of running them in production changed a few things, each after a real incident or near miss:

The lesson behind all of them: a green pipeline only proves the steps ran. Each of these makes the pipeline check the thing that actually matters — that the site is up and serving the code that was tested.


What's Next

Part 5: Hardening the VPS After a Cryptominer Got In — how it got in, and the six layers that keep it out.


The Series

Building Tirraflow is a 5-part series on how the platform you are reading was built and run; every part is linked below.

Part

Topic

Part 1

The stack, the architecture, and what I learned shipping it

Part 2

Non-blocking auth for a SPA, and why Keycloak ended up same-origin

Part 3

Testing the whole frontend with no backend at all

Part 4 (this post)

From push to production, and how the pipeline learned to roll back

Part 5

How it got in, and the six layers that keep it out

Read on Tirraflow