Hoe je van lokale deployments naar snelle CI/CD gaat met GitHub Actions
Ik bouwde aan een webapp voor uitspraakcoaching om mijn Engelse prosodie te verbeteren. Ik ben een moedertaalspreker van Spaans en besloot hem openbaar te maken voor het geval hij andere mensen helpt die Engels als tweede taal leren. De backend is Python met FastAPI, waarbij Parselmouth (een Python-wrapper voor Praat) wordt gebruikt voor audioanalyse en Google Gemini voor het genereren van coachingfeedback. De frontend is React met Vite en TypeScript.
Voor de infrastructuur koos ik voor Google Cloud. De backend draait op Cloud Run, de frontend wordt gehost op Firebase Hosting, en gebruikersgegevens staan in Firestore. Docker-images worden naar Artifact Registry gepusht. De code staat op GitHub. Het is een vrij standaardopzet voor een sideproject dat ik binnen de gratis limieten wilde houden.
Het probleem
Ik implementeerde zowel de frontend als de backend vanaf mijn lokale machine. De frontend ging snel genoeg, maar de backend was pijnlijk. Elke implementatie betekende het vanaf nul bouwen van de Docker-image, wat 5-10 minuten duurde terwijl pip alle Python-afhankelijkheden downloadde.
Ik wilde overstappen op CI/CD zodat implementaties automatisch zouden plaatsvinden wanneer ik naar main pushte. Maar ik wilde ook niet 10 minuten wachten op elke push. Dus ging ik op zoek naar een manier om GitHub Actions slim te maken over wat er daadwerkelijk veranderde.
De monorepo-structuur
Alles staat in één repo met een frontend/ map en een backend/ map. De workflow wordt getriggerd bij een push naar main en ik wilde dat deze zelf uitzocht wat er veranderde en alleen dat deel implementeerde. Frontendbestanden gewijzigd? Implementeer alleen naar Firebase. Backendcode gewijzigd? Implementeer alleen naar Cloud Run. En als requirements.txt veranderde, de Docker-basisimage opnieuw bouwen. Maar alleen dan.
Een manier vinden om wijzigingen te detecteren
Ik zocht rond en vond een actie genaamd dorny/paths-filter. Deze controleert welke bestanden zijn gewijzigd in een push en geeft booleans uit die andere jobs kunnen gebruiken. Ik heb deze ingesteld als de eerste job in mijn workflow:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
requirements: ${{ steps.filter.outputs.requirements }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
requirements:
- 'backend/requirements.txt'
- 'backend/Dockerfile.base'Dit leverde me drie outputs op waarnaar ik in andere jobs kon verwijzen om te beslissen of ze moesten draaien.
Omgaan met trage Docker-builds
Zelfs met werkende wijzigingsdetectie waren backend-implementaties nog steeds traag. Elke build voerde pip install vanaf nul uit. Mijn requirements.txt bevat ML-bibliotheken, audioverwerkingsdingen, van alles. Dat is veel downloaden.
Ik realiseerde me dat ik de afhankelijkheden misschien eens per maand verander. De rest van de tijd verander ik alleen applicatiecode. Dus probeerde ik de Dockerfile in tweeën te splitsen.
Dockerfile.base
Hier heb ik alle trage dingen in gezet: systeempakketten, pip-afhankelijkheden. Het idee was om dit één keer te bouwen en naar Artifact Registry te pushen, en het daarna te hergebruiken.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
espeak-ng \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txtDockerfile.fast
Deze start vanaf de basisimage en kopieert alleen de code. Geen apt-get. Geen pip. Alleen een COPY-commando.
FROM us-central1-docker.pkg.dev/lescoach/prosody-repo/prosody-api-base:latest
WORKDIR /app
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1
ENV PORT=8080
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]Dit bracht de bouwtijd terug van 5-10 minuten naar ongeveer 30 seconden, wat een mooie verbetering was.
De workflow samenstellen
Ik heb de basisimage-job zo ingesteld dat deze alleen draait wanneer afhankelijkheden daadwerkelijk veranderen:
build-base-image:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.requirements == 'true'
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
- name: Build and push base image
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latestDe backend-implementatiejob kostte wat vallen en opstaan. Deze moest wachten op de basisimage-build als deze draaide, maar ook werken als deze werd overgeslagen. Uiteindelijk kwam ik hierop uit:
deploy-backend:
runs-on: ubuntu-latest
needs: [detect-changes, build-base-image]
if: |
always() &&
needs.detect-changes.outputs.backend == 'true' &&
(needs.build-base-image.result == 'success' || needs.build-base-image.result == 'skipped')
steps:
# auth stappen...
- name: Check if base image exists
id: check-base
run: |
if docker manifest inspect ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Build base image if missing
if: steps.check-base.outputs.exists == 'false'
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest
- name: Build and push app image
working-directory: backend
run: |
docker build -f Dockerfile.fast -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }} .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}
- uses: google-github-actions/deploy-cloudrun@v2
with:
service: prosody-api
region: us-central1
image: ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}
flags: --allow-unauthenticatedIk leerde dat always() daar nodig is. Zonder dit evalueert de job zijn voorwaarde niet eens als een upstream job werd overgeslagen. Ik heb ook een docker manifest inspect controle toegevoegd om de situatie af te handelen waarin de basisimage nog niet bestaat, zoals bij een eerste implementatie of als iemand deze per ongeluk heeft verwijderd.
Het frontend-gedeelte
De frontend-job bleek eenvoudiger omdat deze helemaal niet afhankelijk is van de backend:
deploy-frontend:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm run build
working-directory: frontend
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LESCOACH }}
channelId: live
projectId: lescoach
entryPoint: frontendAls alleen de frontend is gewijzigd, is dit de enige job die draait.
Auth-instelling
Ik koos voor Workload Identity Federation in plaats van serviceaccount-sleutels. Het vergde meer initiële instellingen, maar ik vond het prettiger om me geen zorgen te hoeven maken over het roteren van sleutels of JSON-bestanden die in secrets stonden. GitHub krijgt kortdurende tokens via OIDC, wat voor mij schoner aanvoelde.
Waar ik op uitkwam
Alleen-frontend wijzigingen worden nu in minder dan een minuut geïmplementeerd. Wijzigingen in backendcode duren ongeveer 90 seconden. Wijzigingen in afhankelijkheden duren nog steeds ongeveer 4 minuten, maar die zijn zeldzaam voor mij.

De meeste dagen ben ik UI-dingen aan het aanpassen en nu implementeert dat gewoon snel. De hele workflow bleek ongeveer 130 regels YAML te zijn. De paths-filter actie doet het meeste zware werk bij het uitzoeken wat er veranderd is.