CI/CD with GitHub Actions
Why this beats "git pull && restart" for any team larger than one.
Why not just `git pull`
git pull && docker compose up -d --build works fine for one developer on one project. The moment you have two people, or the build takes more than 60 seconds, or you need to roll back fast, you want the artifact (the built image) created once, tagged, and pulled on the server — not rebuilt in place. CI/CD is just "build once, deploy artifacts".
GHCR: free Docker registry for every repo
GitHub Container Registry gives every repo a free private Docker registry at ghcr.io/<owner>/<repo>. No separate signup, uses your GitHub token, and images are free as long as your repo is under the GitHub free tier storage limit (which is generous). Push to GHCR from CI, pull on the VPS. No DockerHub account needed.
The smallest working workflow
name: deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
script: |
cd /srv/app
docker compose pull
docker compose up -d
Two secrets (SSH_HOST, SSH_KEY), one workflow file, a full deploy pipeline.
Tag every build with the commit SHA
Tagging images with :latest and :${{ github.sha }} is the rollback mechanism. When something breaks in production, you set image: ghcr.io/owner/repo:abc123 in compose.yml to the previous SHA and docker compose up -d — 10 seconds to roll back. Without SHA tags, you are rebuilding from the previous commit, which is both slower and more error-prone.
Secrets belong in GitHub, not the repo
Anything sensitive (DATABASE_URL, API keys, SSH keys) lives in GitHub repo secrets — settings → secrets and variables → actions. The workflow references them as ${{ secrets.NAME }}; they never appear in logs, never land in git history, and are scoped to the repo. The two secrets you always need: SSH_HOST and SSH_KEY for the deploy step.
Key takeaways
- Build once in CI, pull artifacts on the server — not `git pull`
- GHCR is a free private Docker registry for every repo
- Tag every image with `:latest` and `:$SHA` for fast rollbacks
- Secrets live in GitHub, never in the repo or the workflow YAML