← BlogFrom Compose to CI/CD: automated deployment with GitHub Actions

From Compose to CI/CD: automated deployment with GitHub Actions

June 10, 2026

I run this site as three services with docker compose: frontend (Next.js), API (FastAPI) and database. Saying "it works" is easy; the real question is how you update it on every change without doing it by hand and without pushing something broken to production. The answer is CI/CD: a build → test → image → deploy pipeline. That's what actually solves the "it worked on my machine" problem.

What does CI/CD actually solve?

It's not about speed, it's about consistency. Manual deploys have little differences every time: a forgotten migration, a skipped test, a wrong env var. A pipeline removes that because every deploy goes through the same, repeatable path and tests sit at the gate.

The pipeline stages

  • Lint & test: pytest for the API, vitest for the frontend. If they fail, the pipeline stops.
  • Build: build each service's image (fast thanks to layer cache).
  • Push: send the image to a registry, tag = commit SHA (every version is traceable).
  • Deploy: pull the new image on the server and refresh the services.

Putting tests at the gate

The most important decision: no deploy without passing tests. In GitHub Actions you express this with needs — the deploy job depends on tests, so a broken commit never reaches production.

name: ci
on:
  push: { branches: [main] }

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12', cache: 'pip' }
      - name: API tests
        run: cd api && pip install -r requirements.txt && pytest -q
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm', cache-dependency-path: front/package-lock.json }
      - name: Front tests
        run: cd front && npm ci && npm test

Building and publishing the image

If tests are green, build the images and push them to a registry. docker/build-push-action + buildx with layer cache speeds this up; the SHA tag binds each deploy to a specific commit (gold for rollbacks).

  build:
    needs: test                      # won't run unless tests pass
    runs-on: ubuntu-latest
    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@v6
        with:
          context: ./front
          push: true
          tags: ghcr.io/kemkum53/site-front:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Deployment strategies

Once the image is in the registry, there are a few ways to tell the server "there's a new version":

  • SSH + compose: connect to the server, docker compose pull && docker compose up -d. Simple and transparent.
  • Watchtower: an agent on the server spots the new image and updates automatically.
  • Zero-downtime: bring up the new container, and once its health check passes, drop the old one (a smooth switch behind Traefik).

Secrets and rollback

Keep secrets (registry password, SSH key) in GitHub Secrets, never in the repo. Thanks to the SHA tag, rolling back is one line: up the previous SHA. Tagging with the SHA instead of latest answers "which version is live?" precisely.

Every manual step is a chance to make a mistake. The real value of a pipeline isn't automation, it's confidence: when you see a green build, you know what reaches production is exactly what was tested.

I describe this architecture itself on the kemalkondakci.me project page.