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