Шаблон актуализирован по чек-листу безопасности. Несколько строк из оригинала убраны — там были советы ради удобства («порт не в секретах, лень каждый раз набирать», «root для homelab допустимо»), которые создают реальные дыры при копировании в prod. Универсальность сохранена: шаблон подходит для любого репозитория.

Что здесь важно до копирования YAML#

GitHub Actions для Docker — это часть цепочки поставки, а не просто удобная автоматизация. Через workflow проходят исходники, registry-токены, образ приложения и иногда доступ к серверу.

Поэтому шаблон нужно читать как инфраструктурный код: какие события запускают сборку, какие secrets доступны, куда публикуется image и что произойдёт, если кто-то изменит workflow в pull request.

Основной workflow#

name: Build and Push Docker Image

on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

concurrency:
  group: build-${{ github.ref }}
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read    # минимум для checkout
      packages: write   # нужен для push в GHCR

    steps:
      - name: Checkout
        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        with:
          fetch-depth: 0  # gitleaks сканирует всю историю

      - name: Secret scan
        uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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

      - name: Login to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=latest,enable=${{ github.ref_name == 'main' || github.ref_name == 'master' }}
            type=ref,event=branch,enable=${{ github.ref_name != 'main' && github.ref_name != 'master' }}
            type=sha,format=short
          labels: |
            org.opencontainers.image.vendor=WebZaytsev
            org.opencontainers.image.version={{version}}
            org.opencontainers.image.created={{date 'YYYY-MM-DD HH:mm:ss'}}
            org.opencontainers.image.revision={{sha}}

      - name: Build image (local, no push yet)
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
        with:
          context: .
          push: false
          load: true
          tags: local:scan
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan image (Trivy)
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: local:scan
          exit-code: '1'
          severity: HIGH,CRITICAL
          scanners: vuln,misconfig,secret
          ignore-unfixed: true

      - name: Push to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          provenance: true

Что изменилось по сравнению с прошлой версией — по пунктам.

Триггеры. Было '**' — сборка запускалась на каждый push в любую ветку. Теперь только main/master плюс pull request к ним. pull_request важен: без этого триггера CI не проверяет код перед мёрджем вообще, и утверждение «push не срабатывает на PR» из старой версии было ошибочным — оно не срабатывало, потому что PR просто не триггерил workflow.

Concurrency. Параллельные push-и в одну ветку отменяют предыдущий прогон. Без этого два деплоя могут стартовать одновременно и оставить сервер в непредсказуемом состоянии.

Permissions. Явный минимум прав на уровне job. Не указывать permissions — значит наследовать широкий дефолт репозитория.

SHA-пины. Все actions зафиксированы по SHA коммита, а не по плавающему тегу вроде @v4. Плавающий тег при переназначении — это supply-chain-атака: кто-то обновляет action, и ваш пайплайн выполняет уже другой код. Обновлять SHA автоматически — через Dependabot (сниппет ниже).

Секреты — не в build-args. Если передать секрет через --build-arg, он осядет в слоях образа и будет виден в docker history. Секреты нужны только в рантайме: через environment variables или BuildKit --mount=type=secret.

Scan-before-push. Образ сначала собирается локально (push: false, load: true), проходит Trivy (уязвимости, мисконфигурации, секреты), и только после этого пушится. Уязвимые образы в GHCR не попадают. Второй шаг build-push-action использует GHA-кэш и не пересобирает образ — просто переупаковывает с нужными тегами и отправляет.

ignore-unfixed: true — не падаем на CVE, у которых ещё нет патча. Без этого флага alpine/debian-образы нередко блокируют сборку из-за уязвимостей в системных пакетах, для которых апстрим ещё не выпустил фикс.

Автодеплой#

Рекомендуемый способ: pull-based#

CI пушит образ — и всё. Сервер сам подтягивает обновление по расписанию или через Watchtower. Никакого SSH-ключа в GitHub Secrets, никакого вектора «через CI зашли на хост».

# cron на сервере — каждые 5 минут проверяет обновление
*/5 * * * * cd /opt/myapp && docker compose pull && docker compose up -d --remove-orphans

Или Watchtower с webhook-уведомлениями — запускается рядом в том же compose и перезапускает контейнеры при появлении нового тега.

Деплой-пользователь на хосте — без sudo, без прямого доступа к docker.sock. Команды только docker compose pull и up -d.

Если без SSH не обойтись#

Если pull-based не вписывается в вашу инфраструктуру — вот hardened-вариант. Нужно понимать: компрометация workflow или утечка secrets → компрометация сервера. Это осознанный выбор, а не случайность.

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production   # опционально: protection rules + ручной approve для прода
    permissions:
      contents: read

    steps:
      - name: Deploy
        uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          port: ${{ secrets.SERVER_PORT }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          fingerprint: ${{ secrets.SSH_HOST_FINGERPRINT }}
          script: |
            set -e
            cd ${{ secrets.SERVER_PATH }}
            docker compose pull
            docker compose up -d --remove-orphans

fingerprint — SHA256-отпечаток публичного ключа хоста, защита от MITM. Получить: ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub | cut -d' ' -f2. Без него action не проверяет, что подключается именно к вашему серверу.

set -e первой строкой в скрипте: прерывает выполнение при любой ошибке. Опция script_stop убрана в новых версиях action — эквивалент теперь именно set -e в самом скрипте.

Все параметры подключения (host, username, port, path) — только в secrets. Один раз завести и не трогать проще, чем разбираться с инцидентом.

Hardening compose на сервере#

Это уже не CI, но пока о деплое: вот минимальный набор ограничений для каждого сервиса в compose.

services:
  myapp:
    image: ghcr.io/org/myapp:latest
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp:uid=1000,gid=1000
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    mem_limit: 256m
    pids_limit: 256
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      retries: 3

У tmpfs при non-root не забудьте uid/gid — иначе каталог создастся от root, и процесс под user: не сможет туда писать.

Dependabot для actions#

Чтобы SHA-пины обновлялись автоматически, добавьте .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

Dependabot будет открывать pull request'ы с обновлёнными SHA и тегами — остаётся смотреть changelog и мёрджить.

Что проверить после копипасты#

  1. Все secrets заведены в настройках репозитория: SERVER_HOST, SERVER_USER, SERVER_PORT, SSH_PRIVATE_KEY, SSH_HOST_FINGERPRINT, SERVER_PATH.
  2. .github/dependabot.yml добавлен, Dependabot включён в настройках репозитория.
  3. Trivy не в warn-only: exit-code стоит '1', а не '0'.
  4. Deploy-пользователь на сервере работает без sudo и без прямого доступа к docker.sock.

Если вам нужен воспроизводимый CI/CD-пайплайн для Docker-сервисов, одной сборки образа мало. Напишите мне в Telegram или на почту — помогу собрать GitHub Actions-процесс с нормальными секретами, deploy-правами, сканированием образов и понятным rollback, чтобы его можно было безопасно использовать в реальном проекте, а не только в демо.