Деплой веб-приложения на Yandex Cloud: Terraform + GitHub Actions от нуля до продакшна
Серия статей о разработке персонального финтех-инструмента:
Часть 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-deployaction не поддерживает 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:
Устанавливает
ycCLI и аутентифицируется через JSON-ключРезолвит ID ресурсов по именам (не хардкодим ID в секретах)
Логинится в Container Registry через IAM-токен
Билдит и пушит Docker-образ с тегом по SHA коммита
Деплоит новую ревизию контейнера
# 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, и какие грабли встретились на каждом из них.