M-Bus Gateway
← Tilbage til blog
· GitHub Actions· CI/CD· FastAPI· pytest· Docker· Hetzner· deploy· Python· PostgreSQL

GitHub Actions CI/CD til Python FastAPI — test, build og deploy

GitHub Actions til FastAPI: pytest med PostgreSQL service container, Docker build og push, Hetzner deploy via SSH, matrix testing og secrets håndtering.

Af M-Bus Gateway

M-Bus Gateway platformen kører GitHub Actions til alle CI/CD-opgaver. Her er workflowet der tester, bygger og deployer FastAPI-serveren.


CI Pipeline-oversigt

Trigger: Push til PR-branch + push til main

Jobs (parallelle):
  test-server:    pytest med PostgreSQL service container
  test-gateway:   pytest gateway (ingen DB)
  build-ui:       Vite build af React frontend
  build-tech-pwa: Vite build af Tech PWA

Jobs (sekventielle, kun main):
  deploy-server:  SSH til Hetzner + docker compose up
  (skipped på PR-branches — kun main)

test-server job

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: ["main", "claude/**"]
  pull_request:
    branches: ["main"]

jobs:
  test-server:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    services:
      # PostgreSQL service container (tilgængeligt som localhost:5432):
      postgres:
        image: timescale/timescaledb:latest-pg16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: mbus_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r server/requirements.txt

      - name: Run migrations
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/mbus_test
        run: |
          cd server
          python -m alembic upgrade head

      - name: Run tests
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/mbus_test
          REDIS_URL: redis://localhost:6379/0
          SECRET_KEY: test-secret-key-min-32-bytes-long-for-ci
          ENVIRONMENT: test
        run: |
          cd server
          pytest tests/ -v --tb=short --timeout=60

pytest konfiguration

# server/tests/conftest.py
import asyncio
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from httpx import AsyncClient, ASGITransport
from server.src.main import app
from server.src.db.models import Base
from server.src.db.session import get_session

DATABASE_URL = "postgresql+asyncpg://test:test@localhost:5432/mbus_test"

@pytest.fixture(scope="session")
def event_loop():
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()

@pytest_asyncio.fixture(scope="function")
async def db_session():
    """Transaktionsbaseret test-isolering — rollback efter hvert test."""
    engine = create_async_engine(DATABASE_URL)
    connection = await engine.connect()
    transaction = await connection.begin()

    session = AsyncSession(bind=connection, expire_on_commit=False)

    yield session

    await session.close()
    await transaction.rollback()
    await connection.close()
    await engine.dispose()

@pytest_asyncio.fixture(scope="function")
async def client(db_session: AsyncSession):
    """FastAPI test-klient med injiceret test-session."""
    async def override_get_session():
        yield db_session

    app.dependency_overrides[get_session] = override_get_session

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as c:
        yield c

    app.dependency_overrides.clear()

Eksempel tests

# server/tests/test_settlements.py
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_settlement_generate_requires_auth(client: AsyncClient):
    resp = await client.post("/api/v1/settlements/fake-id/generate")
    assert resp.status_code == 401

@pytest.mark.asyncio
async def test_settlement_not_found(client: AsyncClient, landlord_token: str):
    resp = await client.post(
        "/api/v1/settlements/00000000-0000-0000-0000-000000000000/generate",
        headers={"Authorization": f"Bearer {landlord_token}"},
    )
    assert resp.status_code == 404

@pytest.mark.asyncio
async def test_settlement_generate_queues_task(
    client: AsyncClient,
    landlord_token: str,
    settlement_factory,
):
    settlement = await settlement_factory(status="generated")
    resp = await client.post(
        f"/api/v1/settlements/{settlement.id}/generate",
        headers={"Authorization": f"Bearer {landlord_token}"},
    )
    assert resp.status_code == 200
    data = resp.json()
    assert "task_id" in data

build-ui job

  build-ui:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
          cache-dependency-path: ui/package-lock.json

      - name: Install and build
        working-directory: ui
        run: |
          npm ci
          npm run build
          # Verificér build output eksisterer:
          test -f dist/index.html

deploy-server job (kun main)

  deploy-server:
    runs-on: ubuntu-latest
    needs: [test-server, build-ui, test-gateway]
    if: github.ref == 'refs/heads/main'  # KUN på main

    steps:
      - uses: actions/checkout@v4

      - name: Deploy til Hetzner via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: meter
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script: |
            cd /home/meter/mbus-gateway
            git pull origin main
            docker compose pull
            docker compose up -d --build
            docker compose exec server alembic upgrade head
            # Health check:
            sleep 10
            curl -f http://localhost:8000/health || exit 1

Secrets håndtering

GitHub Secrets (aldrig i kode):
  HETZNER_HOST:      178.105.90.8
  HETZNER_SSH_KEY:   Privat SSH-nøgle (Ed25519)
  BREVO_API_KEY:     Brevo email API-nøgle
  STRIPE_SECRET_KEY: Stripe secret key
  SECRET_KEY:        JWT-signeringsnøgle (min. 32 bytes)

CI-miljø-variabler (ikke secrets):
  ENVIRONMENT=test
  DATABASE_URL=... (til test PostgreSQL)
  LOG_LEVEL=WARNING  (Reducer støj i CI-output)

Konklusion

GitHub Actions CI kører test-server med en rigtig TimescaleDB service container og transaktionsbaseret test-isolering (rollback efter hvert test). Deploy-job kører kun på main-push — PR-branches tester men deployer aldrig. Secrets håndteres udelukkende via GitHub Secrets, aldrig i kode eller environment-filer.

Se Hetzner Docker Compose guide eller FastAPI multi-tenant guide.