Серия статей о разработке персонального финтех-инструмента:

  • Часть 1: Деплой на Yandex Cloud через GitHub Actions и Terraform (эта статья)

  • Часть 2: Стек и архитектурные решения (React 19, NestJS, Better Auth)

  • Часть 3: Схема данных и доменная модель

  • Часть 4: Интеграция с Т-Банк Инвестиции API

В этой статье — полный разбор инфраструктуры. Поднимаем всё через Terraform, деплоим через GitHub Actions, секреты храним в Lockbox. Никаких кликов в Панели облака — всё воспроизводимо через терраформ.

Что получится в итоге:

Как-то так и выглядит продакшн собранный на коленке
Как-то так и выглядит продакшн собранный на коленке

Секреты — Yandex Lockbox. Инфраструктура — Terraform с бэкендом в Object Storage. CI/CD — GitHub Actions с OIDC-федерацией (без статических ключей в секретах).


Архитектура

Прежде чем лезть в код — почему именно такие сервисы.

Serverless Container для API — NestJS-приложение живёт в Docker-контейнере, который запускается по запросу. Не нужен постоянно работающий VM. Для персонального проекта с непредсказуемым трафиком это дешевле. Минус — cold start, решается параметром min_instances = 1.

Object Storage + CDN для фронтенда — React SPA это просто статика. Object Storage отдаёт файлы, CDN кеширует их на edge-нодах. Дёшево, надёжно, масштабируется само.

API Gateway перед контейнером — даёт HTTPS с managed-сертификатом и кастомный домен без дополнительной настройки. Плюс позволяет блокировать /internal/* роуты на уровне gateway, не в приложении.

Managed PostgreSQL — в приватной подсети без публичного IP. Доступен только из контейнера через VPC. Порт 6432 — это PgBouncer, встроенный в Managed PostgreSQL от YC.

Структура Terraform

terraform/
├── main.tf        # провайдер, backend
├── variables.tf   # переменные
├── outputs.tf     # выходные значения
├── vpc.tf         # сеть и подсеть
├── postgres.tf    # Managed PostgreSQL кластер
├── registry.tf    # Container Registry
├── container.tf   # Serverless Container
├── iam.tf         # сервисные аккаунты, роли, OIDC-федерация
├── lockbox.tf     # секреты
├── apigw.tf       # API Gateway + TLS-сертификат
├── cdn.tf         # CDN ресурсы + сертификаты
├── storage.tf     # Object Storage бакеты
├── dns.tf         # DNS-зона и записи
└── scheduler.tf   # cron-триггеры

Хранилище для состояния

Terraform state храним в Object Storage — это S3-совместимое хранилище YC:

# main.tf
terraform {
  required_providers {
    yandex = {
      source  = "yandex-cloud/yandex"
      version = "~> 0.140"
    }
  }

  backend "s3" {
    bucket   = "finbutler-terraform-state"
    key      = "terraform.tfstate"
    region   = "ru-central1"
    endpoint = "https://storage.yandexcloud.net"

    skip_credentials_validation = true
    skip_region_validation      = true
    force_path_style            = true
  }
}

provider "yandex" {
  token     = var.yc_token
  cloud_id  = var.yc_cloud_id
  folder_id = var.yc_folder_id
  zone      = "ru-central1-a"
}

Для хранилища состояния нужен отдельный файл с ключами доступа — backend.tfvars:

# backend.tfvars — НИКОГДА не коммитить
access_key = "YOUR_STATIC_KEY_ID"
secret_key = "YOUR_STATIC_KEY_SECRET"

Инициализация:

terraform init -backend-config=backend.tfvars

Создаём бакет для стейта заранее вручную — один раз:

yc storage bucket create --name finbutler-terraform-state

(Предварительно стоит установить и сконфигурировать YCloud CLI по инструкции: https://yandex.cloud/ru/docs/cli/operations/install-cli)

Сеть

# vpc.tf
resource "yandex_vpc_network" "finbutler" {
  name = "finbutler-network"
}

resource "yandex_vpc_subnet" "finbutler_a" {
  name           = "finbutler-subnet-a"
  zone           = "ru-central1-a"
  network_id     = yandex_vpc_network.finbutler.id
  v4_cidr_blocks = ["10.0.0.0/24"]
}

PostgreSQL и Serverless Container будут в этой подсети. Публичных IP у базы нет.

PostgreSQL

# postgres.tf
resource "yandex_mdb_postgresql_cluster" "finbutler" {
  name        = "finbutler-pg"
  environment = "PRODUCTION"
  network_id  = yandex_vpc_network.finbutler.id

  config {
    version = 16
    resources {
      resource_preset_id = "s2.micro"
      disk_type_id       = "network-ssd"
      disk_size          = 20
    }
  }

  host {
    zone      = "ru-central1-a"
    subnet_id = yandex_vpc_subnet.finbutler_a.id
    # assign_public_ip не задан — только приватная сеть
  }
}

resource "yandex_mdb_postgresql_database" "finbutler" {
  cluster_id = yandex_mdb_postgresql_cluster.finbutler.id
  name       = "finbutler"
  owner      = yandex_mdb_postgresql_user.finbutler.name
}

resource "yandex_mdb_postgresql_user" "finbutler" {
  cluster_id = yandex_mdb_postgresql_cluster.finbutler.id
  name       = "finbutler"
  password   = var.db_password
}

FQDN (Fully Qualified Domain Name) хоста берём динамически — он известен только после создания кластера:

# lockbox.tf
locals {
  db_host = tolist(yandex_mdb_postgresql_cluster.finbutler.host)[0].fqdn
  database_url = "postgresql://finbutler:${var.db_password}@${local.db_host}:6432/finbutler?sslmode=require"
}

Порт 6432 — встроенный PgBouncer. sslmode=require обязателен для Managed PostgreSQL в YC.

Секреты: Lockbox

Секреты не передаём в контейнер через переменные окружения напрямую — храним в Lockbox и монтируем оттуда:

# lockbox.tf
resource "yandex_lockbox_secret" "database_url" {
  name = "finbutler-database-url"
}

resource "yandex_lockbox_secret_version" "database_url" {
  secret_id = yandex_lockbox_secret.database_url.id
  entries {
    key        = "DATABASE_URL"
    text_value = local.database_url
  }
}

resource "yandex_lockbox_secret" "better_auth_secret" {
  name = "finbutler-better-auth-secret"
}

resource "yandex_lockbox_secret_version" "better_auth_secret" {
  secret_id = yandex_lockbox_secret.better_auth_secret.id
  entries {
    key        = "BETTER_AUTH_SECRET"
    text_value = var.better_auth_secret
  }
}

Сервисные аккаунты и IAM

Два сервисных аккаунта с разными ролями:

finbutler-ci — для GitHub Actions. Пушит образы в Container Registry, деплоит ревизии контейнера, синкает файлы в Object Storage.

finbutler-runtime — для работающего контейнера. Читает секреты из Lockbox, пуллит образы из Registry.

# iam.tf (сокращённо)
resource "yandex_iam_service_account" "finbutler_ci" {
  name = "finbutler-ci"
}

resource "yandex_iam_service_account" "finbutler_runtime" {
  name = "finbutler-runtime"
}

# CI может пушить образы
resource "yandex_resourcemanager_folder_iam_member" "ci_registry_pusher" {
  folder_id = var.yc_folder_id
  role      = "container-registry.images.pusher"
  member    = "serviceAccount:${yandex_iam_service_account.finbutler_ci.id}"
}

# CI может деплоить контейнеры
resource "yandex_resourcemanager_folder_iam_member" "ci_container_admin" {
  folder_id = var.yc_folder_id
  role      = "serverless-containers.admin"
  member    = "serviceAccount:${yandex_iam_service_account.finbutler_ci.id}"
}

# Runtime читает секреты
resource "yandex_resourcemanager_folder_iam_member" "runtime_lockbox_viewer" {
  folder_id = var.yc_folder_id
  role      = "lockbox.payloadViewer"
  member    = "serviceAccount:${yandex_iam_service_account.finbutler_runtime.id}"
}

# Runtime пуллит образы
resource "yandex_resourcemanager_folder_iam_member" "runtime_registry_puller" {
  folder_id = var.yc_folder_id
  role      = "container-registry.images.puller"
  member    = "serviceAccount:${yandex_iam_service_account.finbutler_runtime.id}"
}

OIDC-федерация вместо статических ключей

Вместо того чтобы хранить JSON-ключ сервисного аккаунта в GitHub Secrets, настраиваем Workload Identity Federation. GitHub Actions получает OIDC-токен от GitHub и обменивает его на IAM-токен YC — без долгоживущих секретов:

# iam.tf
resource "yandex_iam_workload_identity_oidc_federation" "github" {
  name      = "finbutler-github-actions"
  folder_id = var.yc_folder_id
  issuer    = "https://token.actions.githubusercontent.com"
  audiences = ["https://github.com/your-org"]
  jwks_url  = "https://token.actions.githubusercontent.com/.well-known/jwks"
}

# Только main-ветка может получить токен
resource "yandex_iam_workload_identity_federated_credential" "github_ci" {
  federation_id       = yandex_iam_workload_identity_oidc_federation.github.id
  service_account_id  = yandex_iam_service_account.finbutler_ci.id
  external_subject_id = "repo:your-org/finbutler-v2:ref:refs/heads/main"
}

Нюанс: yc-sls-container-deploy action не поддерживает OIDC-токены — только JSON-ключи (Как минимум у меня не получилось завести их в разумные сроки). Для этого шага всё равно нужен YC_SA_JSON_CREDENTIALS в GitHub Secrets. Для остальных шагов (Object Storage sync, CDN purge) OIDC работает.

Serverless Container

# container.tf
resource "yandex_serverless_container" "finbutler_api" {
  name               = "finbutler-api"
  memory             = 512
  cores              = 1
  core_fraction      = 100
  execution_timeout  = "30s"
  concurrency        = 8
  service_account_id = yandex_iam_service_account.finbutler_runtime.id

  provision_policy {
    min_instances = 1  # избегаем cold start
  }

  connectivity {
    network_id = yandex_vpc_network.finbutler.id  # доступ к PostgreSQL
  }

  image {
    url = "cr.yandex/${yandex_container_registry.finbutler.id}/finbutler-api:latest"
    environment = {
      NODE_ENV        = "production"
      CORS_ORIGIN     = var.cors_origin
      BETTER_AUTH_URL = var.better_auth_url
    }
  }

  secrets {
    id                   = yandex_lockbox_secret.database_url.id
    version_id           = yandex_lockbox_secret_version.database_url.id
    key                  = "DATABASE_URL"
    environment_variable = "DATABASE_URL"
  }

  secrets {
    id                   = yandex_lockbox_secret.better_auth_secret.id
    version_id           = yandex_lockbox_secret_version.better_auth_secret.id
    key                  = "BETTER_AUTH_SECRET"
    environment_variable = "BETTER_AUTH_SECRET"
  }
}

Параметр connectivity.network_id — ключевой. Без него контейнер не видит PostgreSQL в приватной подсети.

Dockerfile и entrypoint

Multi-stage build — итоговый образ содержит только prod-зависимости:

FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN npx prisma generate --config prisma.config.ts
RUN npm run build
RUN yarn install --frozen-lockfile --production --ignore-scripts

FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nestjs
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/generated ./generated
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/prisma ./prisma
COPY --chown=nestjs:nodejs entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
USER nestjs
EXPOSE 3000
CMD ["./entrypoint.sh"]

Entrypoint запускает миграции перед стартом приложения:

#!/bin/sh
set -e
echo "Running Prisma migrations..."
npx prisma migrate deploy --config prisma.config.ts
echo "Starting application..."
exec node dist/src/main

Важно: exec node ... а не просто node .... С exec процесс Node.js заменяет shell-процесс и получает PID 1 — корректно обрабатывает SIGTERM при остановке контейнера.

API Gateway

Gateway принимает HTTPS-запросы на api.finbutler.ru и проксирует их в контейнер. Маршрут /internal/* блокируется — там живут cron-эндпоинты, которые не должны быть доступны снаружи:

# apigw.tf
resource "yandex_api_gateway" "finbutler_api" {
  name = "finbutler-api-gateway"

  custom_domains {
    fqdn           = "api.${var.domain}"
    certificate_id = yandex_cm_certificate.api.id
  }

  spec = <<-SPEC
    openapi: "3.0.0"
    info:
      title: finbutler-api
      version: "1.0.0"
    x-yc-apigateway:
      service_account_id: ${yandex_iam_service_account.finbutler_runtime.id}
    paths:
      /internal/{path+}:
        x-yc-apigateway-any-method:
          x-yc-apigateway-integration:
            type: dummy
            http_code: 403
            content:
              '*': 'Forbidden'
      /{path+}:
        x-yc-apigateway-any-method:
          x-yc-apigateway-integration:
            type: serverless_containers
            container_id: ${yandex_serverless_container.finbutler_api.id}
            service_account_id: ${yandex_iam_service_account.finbutler_runtime.id}
  SPEC
}

TLS-сертификат через Certificate Manager — выписывается автоматически по DNS-challenge:

resource "yandex_cm_certificate" "api" {
  name    = "finbutler-api-cert"
  domains = ["api.${var.domain}"]
  managed {
    challenge_type = "DNS_CNAME"
  }
}

Object Storage + CDN для фронтенда

Бакет с публичным чтением, website-режим включён (нужен для SPA — все 404 редиректятся на index.html):

# storage.tf
"yandex_storage_bucket"">resource "yandex_storage_bucket" "finbutler_ui" {
  bucket = "finbutler-ui-static"
  # ...

  anonymous_access_flags {
    read = true
    list = false
  }

  website {
    index_document = "index.html"
    error_document = "index.html"  # SPA fallback
  }
}

CDN берёт origin из website-эндпоинта бакета, а не из обычного — это важно. Обычный эндпоинт возвращает 403 на GET /, website-эндпоинт отдаёт index.html:

# cdn.tf
"yandex_cdn_origin_group"">resource "yandex_cdn_origin_group" "finbutler_ui" {
  name = "finbutler-ui-origins"
  origin {
    source = "${yandex_storage_bucket.finbutler_ui.bucket}.website.yandexcloud.net"
  }
}

"yandex_cdn_resource"">resource "yandex_cdn_resource" "finbutler_ui" {
  cname           = "app.${var.domain}"
  active          = true
  origin_protocol = "http"
  origin_group_id = yandex_cdn_origin_group.finbutler_ui.id

  ssl_certificate {
    type                   = "certificate_manager"
    certificate_manager_id = yandex_cm_certificate.ui.id
  }

  options {
    redirect_http_to_https = true
    custom_host_header     = "${yandex_storage_bucket.finbutler_ui.bucket}.website.yandexcloud.net"
  }
}

DNS

# dns.tf
"yandex_dns_zone"">resource "yandex_dns_zone" "finbutler" {
  name   = "finbutler-zone"
  zone   = "${var.domain}."
  public = true
}

# app.finbutler.ru → CDN
"yandex_dns_recordset"">resource "yandex_dns_recordset" "ui" {
  zone_id = yandex_dns_zone.finbutler.id
  name    = "app"
  type    = "CNAME"
  ttl     = 300
  data    = ["${var.cdn_edge_hostname}."]
}

# api.finbutler.ru → API Gateway
"yandex_dns_recordset"">resource "yandex_dns_recordset" "api" {
  zone_id = yandex_dns_zone.finbutler.id
  name    = "api"
  type    = "CNAME"
  ttl     = 300
  data    = ["${yandex_api_gateway.finbutler_api.domain}."]
}

# finbutler.ru (apex) → лендинг CDN
# CNAME на apex запрещён RFC, YC поддерживает ANAME
"yandex_dns_recordset"">resource "yandex_dns_recordset" "landing" {
  zone_id = yandex_dns_zone.finbutler.id
  name    = "@"
  type    = "ANAME"
  ttl     = 300
  data    = ["${var.landing_cdn_edge_hostname}."]
}

Cron-задачи

Scheduled triggers вызывают /internal/* эндпоинты контейнера по расписанию. Расписание в формате crontab, но с ? вместо * для дня недели (YC-специфика):

# scheduler.tf
locals {
  cron_jobs = {
    # Обновление цен акций: каждые 30 минут в торговые часы
    prices-market    = { schedule = "0,30 5-16 * * ?", path = "/internal/cron/refresh-prices" }
    # В нерабочее время — раз в час
    prices-offhours  = { schedule = "0 0-4,17-23 * * ?", path = "/internal/cron/refresh-prices" }
    # Снапшот капитала — ежедневно в 3:00
    snapshot         = { schedule = "0 3 * * ?",         path = "/internal/cron/snapshot" }
    # Синк с Т-Банком — ежедневно в 4:00
    tbank-sync       = { schedule = "0 4 * * ?",         path = "/internal/cron/tbank-sync" }
    # Начисление процентов по вкладам — ежедневно в 18:00
    savings-interest = { schedule = "0 18 * * ?",        path = "/internal/cron/savings-interest" }
  }
}

"yandex_function_trigger"">resource "yandex_function_trigger" "cron" {
  for_each  = local.cron_jobs
  name      = "finbutler-${each.key}"
  folder_id = var.yc_folder_id

  timer {
    cron_expression = each.value.schedule
  }

  container {
    id                 = yandex_serverless_container.finbutler_api.id
    path               = each.value.path
    service_account_id = yandex_iam_service_account.finbutler_runtime.id
  }
}

GitHub Actions: деплой API

Пайплайн при пуше в main:

  1. Устанавливает yc CLI и аутентифицируется через JSON-ключ

  2. Резолвит ID ресурсов по именам (не хардкодим ID в секретах)

  3. Логинится в Container Registry через IAM-токен

  4. Билдит и пушит Docker-образ с тегом по SHA коммита

  5. Деплоит новую ревизию контейнера

# finbutler-api/.github/workflows/deploy.yml
name: Deploy API

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        id: tag
        run: echo "sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT

      - name: Install and configure yc CLI
        env:
          YC_SA_JSON: ${{ secrets.YC_SA_JSON_CREDENTIALS }}
        run: |
          curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash -s -- -a
          echo "$HOME/yandex-cloud/bin" >> $GITHUB_PATH
          export PATH="$HOME/yandex-cloud/bin:$PATH"
          echo "$YC_SA_JSON" > /tmp/sa-key.json
          yc config set service-account-key /tmp/sa-key.json
          yc config set folder-id ${{ secrets.YC_FOLDER_ID }}
          rm /tmp/sa-key.json

      - name: Resolve resource IDs from Yandex Cloud
        id: yc
        run: |
          REGISTRY_ID=$(yc container registry get --name finbutler --format json | jq -r '.id')
          RUNTIME_SA_ID=$(yc iam service-account get --name finbutler-runtime --format json | jq -r '.id')
          NETWORK_ID=$(yc vpc network get --name finbutler-network --format json | jq -r '.id')
          DB_SECRET_ID=$(yc lockbox secret get --name finbutler-database-url --format json | jq -r '.id')
          AUTH_SECRET_ID=$(yc lockbox secret get --name finbutler-better-auth-secret --format json | jq -r '.id')
          echo "registry_id=$REGISTRY_ID" >> $GITHUB_OUTPUT
          echo "runtime_sa_id=$RUNTIME_SA_ID" >> $GITHUB_OUTPUT
          echo "network_id=$NETWORK_ID" >> $GITHUB_OUTPUT
          echo "db_secret_id=$DB_SECRET_ID" >> $GITHUB_OUTPUT
          echo "auth_secret_id=$AUTH_SECRET_ID" >> $GITHUB_OUTPUT

      - name: Log in to Container Registry
        run: |
          yc iam create-token | \
            docker login cr.yandex --username iam --password-stdin

      - name: Build and push Docker image
        env:
          IMAGE: cr.yandex/${{ steps.yc.outputs.registry_id }}/finbutler-api
          TAG: ${{ steps.tag.outputs.sha }}
        run: |
          docker build -t $IMAGE:$TAG -t $IMAGE:latest .
          docker push $IMAGE:$TAG
          docker push $IMAGE:latest

      - name: Deploy container revision
        uses: yc-actions/yc-sls-container-deploy@v4
        with:
          yc-sa-json-credentials: ${{ secrets.YC_SA_JSON_CREDENTIALS }}
          folder-id: ${{ secrets.YC_FOLDER_ID }}
          container-name: finbutler-api
          revision-image-url: cr.yandex/${{ steps.yc.outputs.registry_id }}/finbutler-api:${{ steps.tag.outputs.sha }}
          revision-service-account-id: ${{ steps.yc.outputs.runtime_sa_id }}
          revision-cores: 1
          revision-memory: 512Mb
          revision-provisioned: 1
          revision-concurrency: 8
          revision-execution-timeout: 30
          revision-network-id: ${{ steps.yc.outputs.network_id }}
          revision-secrets: |
            DATABASE_URL=${{ steps.yc.outputs.db_secret_id }}/latest/DATABASE_URL
            BETTER_AUTH_SECRET=${{ steps.yc.outputs.auth_secret_id }}/latest/BETTER_AUTH_SECRET
          revision-env: |
            CORS_ORIGIN=${{ secrets.CORS_ORIGIN }}
            BETTER_AUTH_URL=${{ secrets.BETTER_AUTH_URL }}
            NODE_ENV=production

Разрешаем ID по именам в рантайме вместо хардкода — при пересоздании ресурса через Terraform ID меняется, а имя остаётся. Это защищает от рассинхрона между Terraform state и GitHub Secrets.

GitHub Actions: деплой UI

Два отдельных шага синка с разными Cache-Control — ключевой момент для корректной инвалидации кеша:

# finbutler-ui/.github/workflows/deploy.yml
name: Deploy UI

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.YC_STORAGE_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.YC_STORAGE_SECRET_ACCESS_KEY }}
      AWS_DEFAULT_REGION: ru-central1
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: yarn

      - name: Build
        env:
          VITE_API_URL: ${{ secrets.VITE_API_URL }}
        run: |
          yarn install --frozen-lockfile
          yarn build

      - name: Install AWS CLI
        run: pip install awscli

      # Хешированные ассеты (JS/CSS с content hash в имени) — кешируем на год
      - name: Sync hashed assets
        run: |
          aws s3 sync dist/ s3://${{ secrets.YC_STORAGE_BUCKET_NAME }}/ \
            --endpoint-url https://storage.yandexcloud.net \
            --delete \
            --exclude "*.html" \
            --cache-control "max-age=31536000, immutable"

      # HTML — никогда не кешируем, чтобы получать свежие ссылки на ассеты
      - name: Sync HTML files
        run: |
          aws s3 sync dist/ s3://${{ secrets.YC_STORAGE_BUCKET_NAME }}/ \
            --endpoint-url https://storage.yandexcloud.net \
            --exclude "*" \
            --include "*.html" \
            --cache-control "no-cache, no-store, must-revalidate"

      - name: Install Yandex Cloud CLI
        run: |
          curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash -s -- -a
          echo "$HOME/yandex-cloud/bin" >> $GITHUB_PATH

      - name: Authenticate with Yandex Cloud
        run: |
          echo '${{ secrets.YC_SERVICE_ACCOUNT_KEY_JSON }}' > /tmp/sa-key.json
          yc config set service-account-key /tmp/sa-key.json
          yc config set folder-id ${{ secrets.YC_FOLDER_ID }}

      # Сбрасываем CDN-кеш после деплоя
      - name: Purge CDN cache
        run: |
          yc cdn cache purge \
            --resource-id ${{ secrets.YC_CDN_RESOURCE_ID }} \
            --path '/*'

Логика двух шагов синка: Vite генерирует JS/CSS с content hash в имени файла (main.a3f9c12b.js). Эти файлы можно кешировать навечно — при изменении кода имя файла изменится. HTML-файлы (index.html) не имеют хеша в имени, поэтому их кешировать нельзя — иначе браузер будет загружать старый HTML со ссылками на старые ассеты.

Первый деплой: порядок действий

Terraform нельзя применить одним apply с нуля — есть зависимости, которые нужно разрешать итеративно.

# 1. Создаём бакет для стейта вручную
yc storage bucket create --name finbutler-terraform-state

# 2. Инициализируем Terraform
terraform init -backend-config=backend.tfvars

# 3. Первый apply — создаём инфраструктуру
# На этом шаге CDN-сертификаты выпустятся, но DNS ещё не настроен
terraform apply -var-file=terraform.tfvars

# 4. Получаем outputs — нужны для следующих шагов
terraform output api_url         # → https://xxxx.apigw.yandexcloud.net
terraform output cdn_resource_id # → для YC_CDN_RESOURCE_ID в GitHub Secrets

# 5. Обновляем terraform.tfvars с реальными значениями из outputs
# cors_origin     = "https://app.finbutler.ru"
# better_auth_url = "https://api.finbutler.ru"

# 6. Второй apply — применяем обновлённые переменные
terraform apply -var-file=terraform.tfvars

# 7. Пушим первый Docker-образ вручную (до первого CI-деплоя контейнер пустой)
cd finbutler-api
docker build -t cr.yandex/<REGISTRY_ID>/finbutler-api:latest .
yc iam create-token | docker login cr.yandex --username iam --password-stdin
docker push cr.yandex/<REGISTRY_ID>/finbutler-api:latest

# 8. Настраиваем GitHub Secrets:
# YC_SA_JSON_CREDENTIALS  — из terraform output ci_sa_json_key
# YC_FOLDER_ID            — ваш folder ID
# YC_CDN_RESOURCE_ID      — из terraform output cdn_resource_id
# YC_STORAGE_BUCKET_NAME  — "finbutler-ui-static"
# YC_STORAGE_ACCESS_KEY_ID  — из terraform output ci_static_key_id
# YC_STORAGE_SECRET_ACCESS_KEY — из terraform output (sensitive)
# VITE_API_URL            — из terraform output api_url
# CORS_ORIGIN             — "https://app.finbutler.ru"
# BETTER_AUTH_URL         — "https://api.finbutler.ru"

После этого любой пуш в main автоматически деплоит и API, и UI.

Что получилось

Сервис

Ресурс YC

API

Serverless Container + API Gateway

Frontend

Object Storage + CDN

База данных

Managed PostgreSQL 16

Секреты

Lockbox

Cron

Function Triggers → Container

Инфраструктура

Terraform + S3 backend

CI/CD

GitHub Actions

Всё воспроизводимо с нуля одним terraform apply. Никаких ресурсов созданных вручную через консоль — кроме бакета для стейта.

В следующей части — про стек: почему React 19, NestJS, Better Auth, Prisma, и какие грабли встретились на каждом из них.