· 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.