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 Marketplacerun= 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_apirolls 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 deployinstead 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"