M-Bus Gateway
← Tilbage til blog
· GitHub Actions· Docker· CI/CD· deploy· Hetzner· VPS· DevOps· Python

GitHub Actions: Docker build og deploy til VPS — komplet guide

GitHub Actions CI/CD pipeline for Docker: multi-stage build, push til GHCR, SSH deploy til Hetzner VPS, health checks, rollback og secrets håndtering.

Af M-Bus Gateway

En komplet GitHub Actions pipeline der bygger Docker images, pusher til GHCR og deployer til Hetzner via SSH med zero-downtime strategi.


Workflow-struktur

# .github/workflows/deploy-server.yml

name: Deploy Server

on:
  push:
    branches: [main]
    paths:
      - "server/**"
      - "ui/**"
      - "docker-compose.yml"
      - ".github/workflows/deploy-server.yml"

jobs:
  test:
    uses: ./.github/workflows/test-server.yml  # Genbrugelig workflow

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
      image-digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}/server
          tags: |
            type=sha,prefix=,format=short
            type=ref,event=branch
            latest

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          file: server/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script: |
            set -e
            cd /opt/mbus-gateway
            
            # Pull nyt image
            echo "${{ secrets.GITHUB_TOKEN }}" | \
              docker login ghcr.io -u ${{ github.actor }} --password-stdin
            
            docker pull ghcr.io/${{ github.repository }}/server:${{ needs.build-and-push.outputs.image-tag }}
            
            # Blue-green deploy: start ny container, stop gammel
            docker compose up -d --no-deps --scale server=2 server
            sleep 10
            
            # Health check
            for i in 1 2 3 4 5; do
              if curl -sf http://localhost:8000/api/health; then
                echo "Health check passed"
                break
              fi
              if [ "$i" -eq 5 ]; then
                echo "Health check failed — rolling back"
                docker compose rollback server
                exit 1
              fi
              sleep 5
            done
            
            # Skalér tilbage til 1 instans
            docker compose up -d --no-deps --scale server=1 server
            
            # Ryd gamle images
            docker image prune -f --filter "until=24h"

Dockerfile: multi-stage build

# server/Dockerfile

# Stage 1: Build dependencies
FROM python:3.11-slim AS builder

RUN pip install uv

WORKDIR /build
COPY server/pyproject.toml server/uv.lock ./

RUN uv sync --frozen --no-dev --no-editable

# Stage 2: Frontend build
FROM node:20-slim AS ui-builder

WORKDIR /ui
COPY ui/package.json ui/package-lock.json ./
RUN npm ci --production=false

COPY ui/ .
RUN npm run build

# Stage 3: Runtime
FROM python:3.11-slim AS runtime

# Non-root bruger
RUN groupadd -r mbus && useradd -r -g mbus -s /sbin/nologin mbus

WORKDIR /app

# Kopier Python dependencies fra builder
COPY --from=builder /build/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"

# Kopier server-kode
COPY server/src/ ./src/

# Kopier frontend build
COPY --from=ui-builder /ui/dist/ ./static/

# Sæt ejerskab
RUN chown -R mbus:mbus /app

USER mbus

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
  CMD curl -f http://localhost:8000/api/health || exit 1

CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

Secrets håndtering

# GitHub Secrets (Settings → Secrets → Actions):
# HETZNER_HOST       = 178.105.90.8
# HETZNER_USER       = deploy
# HETZNER_SSH_KEY    = -----BEGIN RSA PRIVATE KEY----- ... (deploy-bruger nøgle)
# DATABASE_URL       = postgresql+asyncpg://...
# JWT_SECRET         = (minimum 64 tegn random hex)
# STRIPE_SECRET_KEY  = sk_live_...

# docker-compose.yml på server — bruger environment secrets:
services:
  server:
    image: ghcr.io/kirken20/mbus-gateway/server:latest
    restart: unless-stopped
    environment:
      DATABASE_URL: ${DATABASE_URL}
      JWT_SECRET: ${JWT_SECRET}
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
      BREVO_API_KEY: ${BREVO_API_KEY}
      ENVIRONMENT: production
    env_file:
      - /etc/mbus-gateway/production.env
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

Health check endpoint

# server/src/health/router.py

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from server.src.db import get_session
import redis.asyncio as aioredis
import asyncio

router = APIRouter(prefix="/api")

@router.get("/health")
async def health_check(session: AsyncSession = Depends(get_session)):
    """GitHub Actions og Docker healthcheck endpoint."""
    checks = {}

    # Database
    try:
        await session.execute(text("SELECT 1"))
        checks["database"] = "ok"
    except Exception as e:
        checks["database"] = f"error: {e}"

    # Redis
    try:
        r = aioredis.from_url("redis://redis:6379")
        await r.ping()
        await r.aclose()
        checks["redis"] = "ok"
    except Exception as e:
        checks["redis"] = f"error: {e}"

    all_ok = all(v == "ok" for v in checks.values())

    return {
        "status": "healthy" if all_ok else "degraded",
        "checks": checks,
    }

Rollback-strategi

# .github/workflows/rollback.yml
# Kørsel manuelt: Actions → Rollback → Run workflow

name: Rollback Server

on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: 'Docker image tag to rollback to (e.g. abc1234)'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Rollback via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script: |
            cd /opt/mbus-gateway
            
            # Opdatér IMAGE_TAG og genstarter
            export SERVER_IMAGE="ghcr.io/kirken20/mbus-gateway/server:${{ inputs.image_tag }}"
            docker pull "$SERVER_IMAGE"
            
            SERVER_IMAGE="$SERVER_IMAGE" docker compose up -d --no-deps server
            
            # Verify
            sleep 15
            curl -sf http://localhost:8000/api/health && \
              echo "Rollback successful" || \
              (echo "Rollback health check failed" && exit 1)

Cache-optimering

# Byg-cache med GitHub Actions Cache (gha):
# Typisk 60-80% hurtigere builds efter første kørsel

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max
    # mode=max: cacher alle lag, ikke kun finale (bedst for multi-stage)

# Python deps cache (uv):
- name: Cache uv dependencies
  uses: actions/cache@v4
  with:
    path: ~/.cache/uv
    key: uv-${{ hashFiles('server/uv.lock') }}
    restore-keys: uv-

# npm cache:
- name: Cache npm dependencies
  uses: actions/cache@v4
  with:
    path: ui/node_modules
    key: npm-${{ hashFiles('ui/package-lock.json') }}

Konklusion

En GitHub Actions pipeline med multi-stage Docker builds, GHCR push og SSH-deploy til Hetzner giver fuldt automatiseret zero-downtime deployment. Brug appleboy/ssh-action til deployment, health checks til verifikation og en separat rollback-workflow til hurtig genopretning. Docker layer-caching med type=gha reducerer byggetider markant.

Se Hetzner VPS Docker SaaS opsætning eller GitHub Actions CI Python FastAPI.