PostgreSQL, FastAPI, GitHub Actions. Источник: авторская картинка (Illustrator)
PostgreSQL, FastAPI, GitHub Actions. Источник: авторская картинка (Illustrator)

❯ Вступление

В этой статье будет разбираться написание GitHub Actions пайплайна на основе моего стенда cicd-practice на GitHub (подробно все можно посмотреть там). Стенд состоит из FastAPI (веб API на Python) и PostgreSQL. Все сервисы разворачиваются в Docker с помощью Docker Compose (микросервисы) на VDS от Timeweb Cloud. Подробнее об остальных инструментах будет ниже, в отдельном блоке.

Для части приложения я также написал около 90 Pytest тестов, которые будут воспроизводиться в пайплайне, но об этом позже.

Backend-часть также будет разбираться, но менее подробно, для общего понимания работы. Уточню, что я не являюсь backend-разработчиком, поэтому часть приложения нужна только для симуляции рабочей архитектуры, поверх которой будет строиться CI/CD.

Немного терминов

  • Пайплайн — задачи организованные в виде непрерывного конвейера для автоматизации работы и передачи результатов от одного этапа к другому

  • CI (Continuous Integration) — это практика автоматической сборки и тестирования кода каждый раз, когда разработчик добавляет новые изменения в общий репозиторий.

  • CD (Continuous Delivery / Deployment) — автоматическая подготовка проверенного кода к релизу или его автоматический запуск на сервере для пользователей

❯ Подробнее про инструменты

Ниже перечислены некоторые основные используемые инструменты:

  • Python 3.13 — основной язык проекта.

  • FastAPI — создание REST API.

  • Uvicorn — запуск FastAPI-приложения.

  • PostgreSQL 18 — основная и тестовая базы данных.

  • SQLAlchemy — работа с базой данных через ORM.

  • Alembic — миграции схемы базы данных.

  • Pydantic — валидация данных и конфигурации.

  • JWT / OAuth2 — аутентификация и авторизация пользователей.

  • Docker — упаковка приложения в контейнеры.

  • Docker Compose — запуск backend, PostgreSQL, pgAdmin и тестов.

  • GitHub Actions — автоматизация CI/CD.

  • Ruff — проверка качества Python-кода.

  • pytest — запуск unit- и интеграционных тестов.

  • Bandit — статический анализ безопасности Python-кода.

  • pip-audit — проверка зависимостей на уязвимости.

  • GitHub Container Registry — хранение Docker-образов.

  • SSH Action — деплой приложения на удалённый сервер.

  • pgAdmin — веб-интерфейс для управления PostgreSQL.

❯ Docker Compose (про каждый микросервис)

Ниже представлен основной docker-compose.yml файл, в котором находятся все развертываемые сервисы.

Основными двумя контейнерами являются backend и main_db, pgadmin4 используется как интерфейс для удобного взаимодействия с базой.

test_db и tests контейнеры нужны только для интеграционных и юнит тестов.

  • Интеграционные тесты — это проверка совместной работы нескольких модулей.

  • Юнит тесты — отдельные проверки определенных частей кода, функций.

services:
  test_db: # Тестовая база данных
    image: postgres:18
    environment:
      - POSTGRES_DB=${TEST_DB_NAME}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_USER=${POSTGRES_USER}
    networks:
      - backend
    healthcheck: # Проверка готовности СУБД перед запуском тестов
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${TEST_DB_NAME}"]
      interval: 5s
      timeout: 5s
      retries: 5
    ports:
      - 5433:5432 # Порт 5433, чтобы не конфликтовать с main_db
    command: >
      postgres -c listen_addresses='*'

  backend: # Основное FastAPI приложение
    image: ${BACKEND_IMAGE:-ghcr.io/canntstand/cicd-practice-backend:latest} # Образ из GHCR после CI/CD
    networks:
      - backend
    volumes:
      - ./backend:/backend # Синхронизация кода с хостом для hot-reload
    environment:
      - DB_URL=${DB_URL}
      - SECRET_KEY=${SECRET_KEY}
      - REFRESH_SECRET_KEY=${REFRESH_SECRET_KEY}
      - ALGORITHM=${ALGORITHM}
      - TOKEN_EXPIRE_MINUTES=${TOKEN_EXPIRE_MINUTES}
      - REFRESH_TOKEN_EXPIRE_MINUTES=${REFRESH_TOKEN_EXPIRE_MINUTES}
      - REFRESH_ALGORITHM=${REFRESH_ALGORITHM}
    command: > # Накат миграций Alembic и запуск Uvicorn
      sh -c "alembic upgrade head && uvicorn app.main:app
      --host 0.0.0.0 --port 8000 --reload --reload-dir /backend/app"
    depends_on:
      main_db:
        condition: service_healthy # Ожидание готовности основной БД
    ports:
      - "8000:8000"

  pgadmin4: # Веб-интерфейс для работы с базой
    image: dpage/pgadmin4:latest
    ports:
      - 5050:80
    environment:
      - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}
      - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}
    depends_on:
      - main_db
    volumes:
      - pgadmin4_data:/var/lib/pgadmin # Хранение настроек pgAdmin
    networks:
      - backend

  tests: # Контейнер для автоматического запуска тестов
    build:
      context: .
      dockerfile: Dockerfile.tests # Изолированный Dockerfile для тестов
    networks:
      - backend
    environment:
      - DB_URL=${TEST_DB_URL}
      - SECRET_KEY=${SECRET_KEY}
      - REFRESH_SECRET_KEY=${REFRESH_SECRET_KEY}
      - ALGORITHM=${ALGORITHM}
      - TOKEN_EXPIRE_MINUTES=${TOKEN_EXPIRE_MINUTES}
      - REFRESH_TOKEN_EXPIRE_MINUTES=${REFRESH_TOKEN_EXPIRE_MINUTES}
      - REFRESH_ALGORITHM=${REFRESH_ALGORITHM}
    command: > # Накат миграций на тестовую БД и запуск Pytest
      sh -c "set -e;
      cd backend && alembic upgrade head;
      cd .. && pytest"
    tty: true
    stdin_open: true
    volumes:
      - ./tests:/test/tests
      - ./backend:/test/backend
    depends_on:
      test_db:
        condition: service_healthy # Запуск тестов только после готовности test_db

  main_db: # Основная база данных (PostgreSQL)
    image: postgres:18
    environment:
      - POSTGRES_DB=${DB_NAME}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_USER=${POSTGRES_USER}
    ports:
      - 5432:5432
    networks:
      - backend
    volumes:
      - pg_main_data:/var/lib/postgresql/ # Хранение постоянных данных БД
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${DB_NAME}"]
      interval: 5s
      timeout: 5s
      retries: 5
    command: >
      postgres -c listen_addresses='*'

volumes: # Именованные тома для сохранения данных
  pg_main_data:
  pgadmin4_data:

networks: # Изолированная сеть для взаимодействия контейнеров
  backend:
    driver: bridge

❯ Архитектура Backend

Ниже представлены 3 схемы, которые отображают общую работу приложения.

 Схема обработки запроса клиента. Источник: авторская схема (Excalidraw)
Схема обработки запроса клиента. Источник: авторская схема (Excalidraw)
Cхема регистрации пользователя. Источник: авторская схема (Excalidraw)
Cхема регистрации пользователя. Источник: авторская схема (Excalidraw)
Cхема авторизации пользователя. Источник: авторская схема (Excalidraw)
Cхема авторизации пользователя. Источник: авторская схема (Excalidraw)

Теперь приведу пример кода аутентификации для наглядности.

У меня логика эндпоинтов разделена на 2 части:

  • В routers: функции, которые принимают веб-запросы, вызывают функции из services и отдают результат.

  • В services: функции, которые отвечают за работу непосредственно с базой данных.

routers/auth.py:

# Импорт основных компонентов FastAPI
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

# Внутренние модули проекта
from ..database.database import get_db
from ..services import auth
from ..utils.dependencies import get_current_user, refresh_access_token
from ..validation import schemas

# Создание роутера с базовым префиксом /api и тегами для Документации (Swagger)
router = APIRouter(prefix="/api", tags=["Auth", "API"])


@router.post("/register", status_code=201)
def register(body: schemas.User, db: Session = Depends(get_db)):
    """Регистрация нового пользователя."""
    # Вызов сервиса регистрации для записи пользователя в БД
    is_success = auth.register(body, db)
    if is_success:
        return {"message": "Successfully registered"}
    raise HTTPException(500, detail="Registration failed")


@router.post("/login", status_code=200, response_model=schemas.TokenResp)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    """Аутентификация пользователя по логину/паролю и выдача JWT-токенов."""
    return auth.login(form, db)


@router.delete("/logout", status_code=204)
def logout(
    refresh_token: str = Cookie(None),
    db: Session = Depends(get_db),
    user_id: int = Depends(get_current_user),  # Проверка авторизации через access_token
):
    if not refresh_token:
        raise HTTPException(400, detail="Refresh token missing")

    # Удаление токена из базы и очистка client-side Cookie
    if auth.logout(refresh_token, db):
        response = Response(status_code=204)
        response.delete_cookie("access_token")
        response.delete_cookie("refresh_token")
        return response
    raise HTTPException(500, detail="something went wrong in logout function")


@router.post("/refresh", status_code=200, response_model=schemas.TokenResp)
def refresh_token_pair(
    refresh_token: str = Cookie(None),
    db: Session = Depends(get_db)
):
    """Обновление пары токенов (Access + Refresh) с использованием Refresh-токена из Cookie."""
    if not refresh_token:
        raise HTTPException(400, detail="Refresh token missing")
    return refresh_access_token(refresh_token, db)

services/auth.py:

import datetime

# FastAPI и инструменты безопасности
from fastapi import HTTPException, Response
from fastapi.security import OAuth2PasswordRequestForm
# Работа с JWT и ORM SQLAlchemy
from jose import jwt
from sqlalchemy import delete
from sqlalchemy.orm import Session

# Внутренние модули приложения
from ..config import settings as ss
from ..database import models
from ..utils.dependencies import create_token_pair
from ..utils.exc import db_exc_check
from ..utils.hash import hash_pwd, verify_pwd
from ..validation import schemas
from . import users


@db_exc_check
def register(body: schemas.User, db: Session) -> bool:
    """Хеширует пароль, создает нового пользователя и сохраняет его в БД."""
    body.password = hash_pwd(body.password)
    user = users.create_user(body, db)
    db.commit()
    return bool(user)


@db_exc_check
def login(
    form: OAuth2PasswordRequestForm, db: Session, response: Response = Response()
) -> schemas.TokenResp:
    """
    Аутентифицирует пользователя, сбрасывает старые refresh-токены,
    генерирует новую пару токенов и устанавливает их в HttpOnly Cookie.
    """
    # Поиск пользователя и проверка валидности пароля
    user = users.get_user_by_form(form, db)
    if not user or not verify_pwd(form.password, user.password):
        raise HTTPException(400, detail="Invalid credentials")

    # Аннулирование предыдущих refresh-токенов пользователя (сессии)
    db.execute(delete(models.RefreshToken).where(models.RefreshToken.owner == user.id))

    # Генерация новой пары токенов и запись refresh-токена в БД
    token_pair = create_token_pair(user.id)
    _save_refresh_token(db, user.id, token_pair.refresh_token)

    # Установка безопасных HttpOnly Cookie с токенами в ответ
    response.set_cookie(
        key="access_token",
        value=token_pair.access_token,
        httponly=True,
        secure=True,
        max_age=15 * 60,  # 15 минут
    )
    response.set_cookie(
        key="refresh_token",
        value=token_pair.refresh_token,
        httponly=True,
        secure=True,
        max_age=7 * 24 * 60 * 60,  # 7 дней
    )
    db.commit()
    return token_pair


def _save_refresh_token(db: Session, user_id: int, refresh_token: str):
    """Декодирует exp из JWT и сохраняет refresh-токен в базу данных."""
    expires = datetime.datetime.fromtimestamp(
        jwt.decode(
            refresh_token,
            ss.REFRESH_SECRET_KEY,
            algorithms=[ss.REFRESH_ALGORITHM],
        )["exp"],
        tz=datetime.timezone.utc,
    )
    db.add(models.RefreshToken(token=refresh_token, owner=user_id, expires_at=expires))
    db.commit()


@db_exc_check
def logout(refresh_token: str, db: Session) -> bool:
    """Удаляет указанный refresh-токен из базы данных для завершения сессии."""
    db.execute(
        delete(models.RefreshToken).where(models.RefreshToken.token == refresh_token)
    )
    db.commit()
    return True

❯ Кратко про Pytest

Pytest в этом проекте используется для автоматической проверки работы приложения и базы данных. Он запускает тесты, проверяет API, бизнес-логику и корректность схемы данных, а затем сообщает, что прошло, а что сломалось.

В проекте есть набор тестов в папке tests, и CI-пайплайн запускает их автоматически после сборки и перед деплоем, чтобы ловить ошибки.

Файлы с тестами начинаются с test_, поэтому Pytest находит их автоматически.

Пример функций тестов

class TestLogin: # Тесты для эндпоинта авторизации /api/login
    # Успешная авторизация с правильным логином и паролем (код 200)
    def test_post_login_returns_200(self, register: None, client: TestClient):
        resp = client.post(
            "/api/login",
            data={"username": "example", "password": "example123"},
        )
        assert resp.status_code == 200

    # Попытка входа с неверным логином или неверным паролем (код 400)
    @pytest.mark.parametrize(
        "username,password", [("false", "example123"), ("example", "false")]
    )
    def test_post_login_with_invalid_credentials_returns_400(
        self, register: None, username: str, password: str, client: TestClient
    ):
        resp = client.post(
            "/api/login",
            data={"username": username, "password": password},
        )
        assert resp.status_code == 400

    # Передача незаполненных полей — ошибка валидации FastAPI (код 422)
    @pytest.mark.parametrize(
        "data",
        [
            ({"username": None, "password": "example123"}),
            ({"username": "example", "password": None}),
        ],
    )
    def test_post_login_with_missing_fields_returns_422(
        self, register: None, data: dict, client: TestClient
    ):
        resp = client.post(
            "/api/login",
            data=data,
        )
        assert resp.status_code == 422

❯ Кратко про Alembic

Для этой статьи данный инструмент не так сильно важен, поэтому кратко напишу зачем он вообще нужен, и можно идти дальше.

Alembic в проекте используется для управления структурой базы данных PostgreSQL.

Вместо ручного изменения таблиц всё фиксируется в отдельной миграции: например, добавление столбца, индекса, ограничения или новой таблицы.

Миграции хранятся в backend/alembic/versions и выполняются последовательно через функции upgrade() и downgrade(), что позволяет как обновлять базу, так и откатывать изменения.

Alembic получает модели SQLAlchemy и подключается к базе через DB_URL, благодаря этому таблицы базы остаются синхронизированными с кодом приложения.

❯ Архитектура GitHub Actions пайплайна

Мой CI/CD состоит из:

  1. Линтинг кода и конфигов

  2. Интеграционные и юнит тесты

  3. Проверки безопасности

  4. Сборка docker образов и отправка в ghcr.io

  5. Подключение к серверу, скачивание образов и репозитория, развертывание базы данных и приложения.

Схема CI. Источник: авторская схема (Excalidraw)
Схема CI. Источник: авторская схема (Excalidraw)
Схема CD. Источник: авторская схема (Excalidraw)
Схема CD. Источник: авторская схема (Excalidraw)

❯ Настройка Continuous Integration (CI)

Главная задача этапа CI — убедиться, что каждый pull запрос или коммит в основную ветку содержит рабочий код, проходящий линтеры и интеграционные тесты.

Главный файл пайплайна хранится в .github/workflows/ci-cd.yml.

Линтинг конфигов и кода

name: CI/CD Pipeline # Название пайплайна

on: # Когда запускать
  push:
    branches: ["main"] # При пуше в ветку main
  pull_request:
    branches: ["main"] # При создании/обновлении PR в main

env: # Переменные
  REGISTRY: ghcr.io # Реестр для Docker-образов
  IMAGE_NAME: ${{ github.repository }} # Название репозитория

jobs: 
  lint: # Задача проверки кода
    name: Code & Config Linting
    runs-on: ubuntu-latest # Запускаем на Ubuntu

    steps:
      - uses: actions/checkout@v4 # Скачиваем код репозитория

      - name: Yamllint # Проверка файла docker-compose
        uses: karancode/yamllint-github-action@v2.1.1
        with:
          yamllint_file_or_dir: "docker-compose.yml"
          yamllint_strict: false

      - name: Set up Python # Установка Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - name: Run Ruff (Linter) # Проверка кода линтером
        run: |
          pip install ruff
          ruff check . # Проверяем все .py файлы

Проверки безопасности

  security: # Задача проверки безопасности
    name: Security Checks
    runs-on: ubuntu-latest # Запускаем на Ubuntu
    permissions: # Права для GITHUB_TOKEN
      security-events: write # Разрешение на отправку отчетов по безопасности
      contents: read # Разрешение на чтение кода
    steps:
      - uses: actions/checkout@v4 # Скачиваем код репозитория

      - name: Set up Python # Установка Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - name: Security audit for dependencies # Проверка зависимостей на известные уязвимости
        uses: pypa/gh-action-pip-audit@v1.1.0
        with:
          inputs: backend/requirements.txt
          ignore-vulns: PYSEC-2026-1325 # Игнорируем уязвимость ecdsa (зависимость python-jose, нет апдейтов)

      - name: Perform Bandit Analysis # Сканирование кода Python на уязвимости (Bandit)
        uses: PyCQA/bandit-action@v1
        with:
          path: "backend" # Проверяемая директория

Тестирование работы

  tests: # Задача запуска тестов
    name: Integration & Unit Tests
    runs-on: ubuntu-latest # Запускаем на Ubuntu
    steps:
      - uses: actions/checkout@v4 # Скачиваем код репозитория

      - name: Create .env file # Создаем .env с переменными (из secrets/vars или со значениями по умолчанию)
        run: | # На практике значения по умолчанию лучше не добавлять, чтобы избежать неожиданностей в продакшене, даже если все тесты прошли успешно
          cat << EOF > .env
          TEST_DB_NAME=${{ vars.TEST_DB_NAME || 'test_db' }}
          DB_NAME=${{ vars.DB_NAME || 'main_db' }}
          DB_PASSWORD=${{ secrets.DB_PASSWORD || 'example123' }}
          POSTGRES_USER=${{ vars.POSTGRES_USER || 'example' }}
          TEST_DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@test_db:5432/${{ vars.TEST_DB_NAME || 'test_db' }}
          DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@main_db:5432/${{ vars.DB_NAME || 'main_db' }}
          SECRET_KEY=${{ secrets.SECRET_KEY || 'example_key' }}
          REFRESH_SECRET_KEY=${{ secrets.REFRESH_SECRET_KEY || 'example_refresh' }}
          ALGORITHM=${{ vars.ALGORITHM || 'HS256' }}
          REFRESH_ALGORITHM=${{ vars.REFRESH_ALGORITHM || 'HS256' }}
          TOKEN_EXPIRE_MINUTES=30
          REFRESH_TOKEN_EXPIRE_MINUTES=1440
          PGADMIN_DEFAULT_EMAIL=admin@example.com
          PGADMIN_DEFAULT_PASSWORD=example123
          EOF

      - name: Start services # Поднимаем базы данных и ждем их полной готовности (--wait)
        run: docker compose up -d --wait main_db test_db

      - name: Run tests via Docker Compose # Запускаем тесты и удаляем тестовый контейнер после финиша (--rm)
        run: docker compose run --rm tests

❯ Настройка Continuous Deployment (CD)

После того как код прошел все проверки на этапе CI, его необходимо автоматически доставить на сервер, a все собранные образы нужно отправить в registry.

Cборка образов и отправка в ghcr.io

  build-and-push: # Задача сборки и публикации Docker-образа
    name: Build & Push Docker Image
    needs: [lint, tests, security] # Ждем успешного выполнения прошлых трех задач
    if: github.ref == 'refs/heads/main' && github.event_name == 'push' # Запуск только при прямом пуше в main (не для PR)
    runs-on: ubuntu-latest # Запускаем на чистой Ubuntu
    permissions: # Права для загрузки образов
      contents: read # Чтение кода
      packages: write # Запись в реестр пакетов (GHCR)
    steps:
      - uses: actions/checkout@v4 # Скачиваем код репозитория

      - name: Log in to GitHub Container Registry # Авторизация в GHCR с помощью системного токена
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker # Автосоздание тегов (latest и SHA коммита)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
          tags: |
            type=raw,value=latest
            type=sha,prefix=

      - name: Set up Docker Buildx # Включение Buildx для поддержки кэширования
        uses: docker/setup-buildx-action@v3

      - name: Build and push Docker image # Сборка Dockerfile из директории backend и отправка в GHCR
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          file: ./backend/Dockerfile
          push: true # Публикуем собранный образ
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha # Использование кэша GitHub Actions для ускорения сборки
          cache-to: type=gha,mode=max

Деплой на сервер

Теперь стоит уточнить, что CI/CD прежде всего нужен для автоматизации и безопасности при изменении кодовой базы. Это значит, что он не должен отвечать за первоначальную настройку сервера. Для этого лучше подойдет, к примеру, Ansible.

В данный момент для работы на сервере нужно:

  1. Установить Git

  2. Установить Docker Engine

  3. Склонировать репозиторий по пути /opt/cicd-practice

  deploy: # Задача деплоя на удаленный сервер
    name: Deploy to Server
    needs: [build-and-push] # Ждем успешного завершения сборки и пуша образа
    if: github.ref == 'refs/heads/main' && github.event_name == 'push' # Запуск только при пуше в main
    runs-on: ubuntu-latest # Запускаем на Ubuntu
    steps:
      - name: Deploy via SSH # Подключение к серверу по SSH и запуск команд
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            set -e # Остановка выполнения при любой ошибке

            cd /opt/cicd-practice # Переход в директорию проекта на сервере
            git pull origin main # Обновление исходного кода из репозитория

            cat << EOF > .env # Создание .env файла с переменными окружения
            TEST_DB_NAME=${{ vars.TEST_DB_NAME || 'test_db' }}
            DB_NAME=${{ vars.DB_NAME || 'main_db' }}
            DB_PASSWORD=${{ secrets.DB_PASSWORD || 'example123' }}
            POSTGRES_USER=${{ vars.POSTGRES_USER || 'example' }}
            TEST_DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@test_db:5432/${{ vars.TEST_DB_NAME || 'test_db' }}
            DB_URL=postgresql://${{ vars.POSTGRES_USER || 'example' }}:${{ secrets.DB_PASSWORD || 'example123' }}@main_db:5432/${{ vars.DB_NAME || 'main_db' }}
            SECRET_KEY=${{ secrets.SECRET_KEY || 'example_key' }}
            REFRESH_SECRET_KEY=${{ secrets.REFRESH_SECRET_KEY || 'example_refresh' }}
            ALGORITHM=${{ vars.ALGORITHM || 'HS256' }}
            REFRESH_ALGORITHM=${{ vars.REFRESH_ALGORITHM || 'HS256' }}
            TOKEN_EXPIRE_MINUTES=30
            REFRESH_TOKEN_EXPIRE_MINUTES=1440
            PGADMIN_DEFAULT_EMAIL=admin@example.com
            PGADMIN_DEFAULT_PASSWORD=example123
            BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:latest
            EOF

            echo "${{ secrets.DEPLOY_TOKEN || secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin # Авторизация сервера в GHCR

            docker compose pull backend # Скачивание обновленного Docker-образа
            docker compose up -d --remove-orphans backend pgadmin4 main_db # Перезапуск сервисов в фоновом режиме

❯ Подготовка переменных

Для CI/CD в .env значения хранить нельзя ни в коем случае, иначе они будут видны при выполнении пайплайна. Поэтому, в нашем случае, переменные нужно создать в самом GitHub.

  • Repository Secrets — это зашифрованные конфиденциальные данные (пароли, API-ключи, SSH-ключи), которые маскируются в логах и недоступны для просмотра в интерфейсе после создания.

  • Repository Variables — это открытые конфигурационные параметры (имена баз данных, порты, URL, флаги), которые хранятся в открытом виде и отображаются в логах выполнения workflows.

Repository Secrets

  • SERVER_HOST — IP-адрес или доменное имя вашего VPS/сервера.

  • SERVER_USER — имя SSH-пользователя на сервере.

  • SSH_PRIVATE_KEY — приватный SSH-ключ для подключения к серверу.

  • DB_PASSWORD — пароль от СУБД PostgreSQL.

  • SECRET_KEY — секретный ключ приложения для подписи Access JWT-токенов.

  • REFRESH_SECRET_KEY — секретный ключ приложения для подписи Refresh-токенов.

Repository Variables

  • POSTGRES_USER — имя пользователя (логин) PostgreSQL.

  • DB_NAME — название основной базы данных.

  • TEST_DB_NAME — название базы данных для запуска тестов.

  • ALGORITHM — алгоритм шифрования Access JWT-токена.

  • REFRESH_ALGORITHM — алгоритм шифрования Refresh-токена.

❯ Заключение

Мы пошагово создали надежный CI/CD-пайплайн для проекта на FastAPI и PostgreSQL. Теперь при создании pull request код автоматически проверяется на ошибки стилистики и проходит интеграционные тесты с реальной базой данных. После одобрения изменений код бесшовно деплоится на сервер.

Полный исходный код проекта с готовыми конфигурационными файлами и выполненными пайплайнами доступен в репозитории canntstand/cicd-practice.

Если вам интересна тема CI/CD, автоматизации и разработки, делюсь своими практическими заметками и опытом в Telegram-канале My Tech Notes.

Может быть интересно:
Перейти ↩

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале 

Комментарии (0)