Микро-SaaS на 1 ГБ RAM: Telegram-мини-апп, СБП и один очень дорогой дефис

—

от автора

Как «Паспорт вещей» (Next.js + Prisma + PostgreSQL) живёт на самом дешёвом VDS, зачем MAX нужен мост и собственный корень Минцифры, и почему платежи не работали с первого дня — а никто не замечал.

Дисклеймер

Это не туториал, а полевой отчёт соло-разработчика. Продукт — «Паспорт вещей» (паспорт-вещей.рф): цифровой паспорт покупок с напоминаниями о гарантии в Telegram/MAX. Бесплатно — до 5 вещей и все функции для авто (пробег, ТО, история); подписка «Открытый» (59 ₽/мес, СБП) — безлимит, OCR чеков, CSV и документы. Стек: Next.js 16, Prisma + PostgreSQL, grammy, ЮKassa. Всё это — на VDS с 1 vCPU и 1 ГБ RAM.

Урок 1: сборка на слабом сервере — это OOM

Первая же docker compose build на 1-гигабайтном сервере уронила машину: sshd и контейнеры были убиты OOM-киллером, помогла перезагрузка через панель хостера. С тех пор правило железное: сборка — на локальной машине, на сервер — docker save | ssh docker load. Бонус: чиним место на диске заранее (образы плодятся).

Урок 2: Idempotence ≠ Idempotency

Самая дорогая буква в проекте. Платежи через ЮKassa у нас создаются POST-ом с идемпотентным ключом. Стандартное имя заголовка — Idempotency-Key? У ЮKassa — нет: контрактное имя Idempotence-Key. Мы отправляли «правильное» имя — ЮKassa молча отвечала 400 «Idempotence key is missing», а пользователю показывалось «Не получилось создать платёж».

Прелесть в том, что дефект жил с первого дня: ни одного платежа не прошло никогда, но и никто не платил — поэтому узнавали мы об этом только через месяц с лишним, когда прогнали полный цикл на тестовом магазине. Морали три:

  1. Юнит-тест на моке закреплял неверное имя — тестируйте контракт, а не свою интерпретацию контракта.

  2. Полный платёжный e2e на тестовом окружении — до релиза, а не после.

  3. Ошибка создания платежа обязана логироваться со статусом и телом ответа.

Кстати о тестовом магазине ЮKassa: в нём нет СБП — только тестовые карты. Поэтому в тестовом режиме создаём платёж без payment_method_data и способ выбирается на стороне ЮKassa.

Урок 3: вебхукам нельзя верить, а return_url — можно (немного)

Тело вебхука мы не доверяем: по id платежа дергаем живой GET к API ЮKassa, сверяем сумму с прайсом, и в одной транзакции клеймим Payment.subscriptionId + выдаём подписку. Повторный вебхук видит клейм и молчит. А ещё возврат с оплаты на ?paid=1 сам запускает перепроверку — страховка от «вебхук опоздал».

Отдельная история — куда возвращать пользователя. Оплата из мини-аппа открывается в браузерном контейнере мессенджера, и жёсткий return_url=/price приводил к странице тарифов с кнопкой «Войти» (cookie-сессии в контексте возврата могло не оказаться). Решение: возвращать на /app — точку, которая при отсутствии сессии сама тихо логинит через мост мессенджера.

Урок 4: MAX — это Telegram-совместимый протокол, но

Мини-аппы MAX валидируются как Telegram-initData (без signature), но initData реально приходит только через самописный мост — hash-фолбэк не работает. API платформы требует российского корня TLS (добывали по цепочке AIA от госУЦ). А кнопка мини-приложения в боте ставится только через бизнес-кабинет — API для неё нет.

Урок 5: словарь на 4000 строк из CSV

Для авто-раздела нужен был словарь марок/моделей. Ничего экзотического: две таблицы (VehicleMake/VehicleModel), сид из CSV, автокомплит. Интересны грабли:

  • SQLite (тесты/локал) и PostgreSQL (прод) расходятся семантикой LIKE: PG крутит %/_ как wildcards, SQLite не склоняет кириллицу по регистру. Ветвление «по провайдеру» дало расхождение дев/прод — «ваз» находил «ВАЗ (Lada)» в деве, но не наоборот. Выход: при ~400 строках поиск дешевле делать одним JS-фильтром на обеих БД.

  • В автокомплите активный индекс — самая хрупкая вещь: после Escape список скрыт, но Enter всё ещё «выбирал» невидимую подсказку. Лечится гейтом «open» и сбросом индекса при каждом обновлении списка.

Урок 6: тарифный ревамп — отдать платное бесплатно

Стартовали с аддоном «Авто» за 99 ₽/мес (пробег и ТО). По итогам беты пересобрали модель: авто-функции ушли в бесплатный тариф целиком, а подписка «Открытый» (59 ₽/мес · 599 ₽/год) продаёт безлимит вещей, OCR чеков, CSV и документы. Логика: резать то, что уже было бесплатным, нельзя — а вот отдать бесплатным самый громкий вход в продукт можно, если платным остаётся то, что экономит время. Слишком рано судить, но ревамп дал повод наконец раздать бесплатный авто-раздел прессе и каналам — как раз то, что эта статья и делает.

Что дальше

Продукт живёт, платит за себя хостинг. Семейный доступ уже выкачен. Ближайшие планы — публичный API, каталог регламентов по моделям. Если тема микро-SaaS на слабом железе интересна — расскажу отдельно про тюнинг PostgreSQL под 1 ГБ и cron-оптимизацию под напоминания.

Бот: @VeshiPassportBot, сайт: паспорт-вещей.рф. Критика кода и продуктовых решений приветствуется в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1099314/