Skip to content

AverAzure — CI/CD and GitHub Actions Session Notes

What we built

A GitHub Actions pipeline that: 1. Triggers on push to master 2. Builds the Docker image 3. Pushes to GHCR with two tags (latest and commit SHA) 4. SSHes into the Swarm manager and runs docker service update


GitHub Actions core concepts

What a workflow is

A YAML file in .github/workflows/ in your repo. GitHub reads it and runs it automatically on the trigger you define. Runs on GitHub's own servers (runners) — you don't provision anything.

Anatomy of a workflow

name: Build and Deploy          # Display name in GitHub UI

on:                             # Trigger
  push:
    branches:
      - master                  # Only on pushes to master

jobs:                           # One or more jobs
  build-and-deploy:             # Job name
    runs-on: ubuntu-latest      # GitHub spins up a fresh Ubuntu VM

    steps:                      # Sequential steps in the job
      - name: Step name
        uses: some/action@v1    # Pre-built action
        # or
        run: echo "shell command"

Key things to remember

  • Each run gets a fresh VM — nothing persists between runs
  • Jobs run in parallel by default — steps within a job run sequentially
  • uses = pre-built action from GitHub Marketplace
  • run = shell command you write yourself
  • ${{ secrets.MY_SECRET }} = reference a GitHub Secret — never visible in logs
  • ${{ github.actor }} = username who triggered the workflow
  • ${{ github.sha }} = full commit SHA that triggered the workflow

@v numbers

Version pins — actions/checkout@v4 means use version 4 of that action. Actions are just code in GitHub repos. Pinning prevents breaking changes from affecting your pipeline. You'll also see @v4.1.2 for exact pinning or @sha256:abc for absolute immutability.


The workflow file explained step by step

- name: Checkout code
  uses: actions/checkout@v4

Clones your repo onto the runner. Without this, the runner has no code to work with. It automatically checks out the branch/commit that triggered the workflow — you don't specify it.

- name: Login to GHCR
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GHCR_TOKEN }}

Authenticates to GitHub Container Registry. We used a PAT stored as GHCR_TOKEN because GITHUB_TOKEN (auto-generated) had permission issues with the existing package. Remember: GITHUB_TOKEN works when the package is created by the workflow itself. When the package already exists and was created manually, a PAT is safer.

- name: Build and push image
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: |
      ghcr.io/abhishek052off/averazure:latest
      ghcr.io/abhishek052off/averazure:${{ github.sha }}

Builds the Dockerfile and pushes two tags: - latest — always points to most recent build, what Swarm pulls - ${{ github.sha }} — immutable, tied to exact commit. Used for rollback: docker service update --image ghcr.io/abhishek052off/averazure:<old-sha> aver_api

- name: Deploy to Swarm
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.MANAGER_IP }}
    username: azureuser
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      docker service update --with-registry-auth --image ghcr.io/abhishek052off/averazure:latest aver_api

SSHes into the manager using the private key stored in GitHub Secrets. Runs docker service update which tells Swarm to pull the new image and do a rolling update. Zero downtime because of start-first in the stack file update config.


GitHub Secrets used

Secret Value Why
GHCR_TOKEN GitHub PAT with packages:write Push images to GHCR
MANAGER_IP 20.219.66.48 SSH target
SSH_PRIVATE_KEY Contents of aver-key.pem SSH auth into manager
GITHUB_TOKEN Auto-generated by GitHub Not used for GHCR here but available for other things

What we messed up / learnt the hard way

Branch name mismatch

Workflow had branches: main but repo uses master. Pipeline never triggered. Fixed by changing to master. Remember: always check your default branch name before wiring triggers.

GITHUB_TOKEN package permissions

Initially used GITHUB_TOKEN for GHCR login. Got denied: installation not allowed to Write organization package. This is because the package was created manually — GITHUB_TOKEN can only write packages created by the workflow itself. Switched to PAT stored as GHCR_TOKEN. Could also fix via repo Settings → Actions → General → Workflow permissions → Read and write.

SSH open to public

Opening port 22 to Any in NSG is necessary because GitHub runner IPs are dynamic — GitHub publishes ranges at https://api.github.com/meta but they change weekly and runners sometimes operate outside published ranges. GitHub explicitly says not to use these as allowlists. SSH key auth is your actual security layer — no password auth means brute force is impossible. For production: use a self-hosted runner so GitHub never SSHes in and port 22 stays locked.


Concepts covered

GITHUB_TOKEN vs PAT

  • GITHUB_TOKEN — auto-generated per workflow run, short-lived, scoped to the repo that triggered the workflow. Fine for most things. Struggles with packages created outside the workflow.
  • PAT — manually created, longer-lived, broader scope. Stored as a GitHub Secret. More explicit control.

Why two image tags

  • latest — convenience, always points to newest build
  • SHA tag — traceability and rollback. docker service update --image ghcr.io/.../averazure:abc123 aver_api rolls back to exact commit instantly.

What the pipeline doesn't handle

Stack file changes — docker stack deploy is not in the pipeline, only docker service update for the api image. If you change docker-stack.yml (add service, change port, update config) you still need to SSH in manually and run docker stack deploy. Fix: change deploy step to git pull && docker stack deploy -c docker-stack.yml --with-registry-auth aver.

GitOps (conceptual — not implemented)

Two repo pattern: - App repo: build image, push with SHA tag, open PR on infra repo updating the tag - Infra repo: just the stack file, pipeline deploys on merge

Value: stack file is always source of truth, rollback is just reverting a PR, app and infra changes have separate audit trails. Overkill for single service on two VMs. Worth knowing for interviews.

Self-hosted runner (not implemented, worth knowing)

A container running on your manager that polls GitHub for jobs. GitHub never SSHes in — runner pulls jobs and executes locally. Port 22 stays locked to your IP. Canonical production approach when you can't expose SSH publicly.


Deployment flow summary

Push to master
    → GitHub Actions triggers
        → Fresh Ubuntu runner spins up
        → Repo cloned
        → Login to GHCR with PAT
        → Docker image built from Dockerfile
        → Pushed as :latest and :<sha> to GHCR
        → SSH into 20.219.66.48 (aver-manager)
        → docker service update pulls new image
        → Swarm rolling update: new container starts, health checked, old stopped
        → Zero downtime

What's next

  • Change deploy step to full docker stack deploy instead of just service update — handles stack file changes too
  • Self-hosted runner — removes public SSH exposure, proper production pattern
  • Lock SSH back to your IP after pipeline is proven — use self-hosted runner instead
  • Interview narrative — "push to master triggers build, GHCR push, and zero-downtime Swarm rolling update via SSH action"