П/ВИН

Staging сайт клиники: клон, деплой и форма кастдева

·8 мин чтения

Однажды мне прилетела задача: «скопируй главную страницу и подними staging сайт на поддомене». Звучит на 15 минут работы - но внутри оказался целый слой инфраструктурных решений: wget, nginx, Traefik, DNS-записи, GitHub Actions. И параллельно - форма сбора заявок, которая поначалу тихо теряла лиды из-за слишком строгой валидации телефонных номеров.

Зачем вообще копировать сайт на поддомен

Задача была конкретная: есть рабочий сайт медицинской клиники norma33.ru. Трогать его нельзя — там живые пациенты, живые записи. Но хочется экспериментировать с дизайном, тестировать новые блоки, менять тексты — и всё это без риска сломать прод. Решение очевидное: сделать pixel-perfect копию главной страницы и задеплоить её на norma33.pashavin.ru как черновик.

Это классический подход в веб-разработке — staging-окружение, только в данном случае не для кода, а для дизайна. Ты работаешь с копией, видишь результат в реальном браузере, и только когда всё устраивает — переносишь изменения на оригинал.

Скачиваем сайт: wget и его причуды

Первый инструмент — wget с флагом -k (convert links). Он скачивает страницу вместе со всеми ресурсами и конвертирует ссылки в относительные, чтобы сайт работал локально. Звучит идеально, но на практике wget делает кое-что лишнее: он добавляет расширение .html ко всем файлам, включая шрифты.

В результате в CSS появляются строки вроде:

@font-face {
  font-family: 'GothamPro';
  src: url('../fonts/GothamPro.woff2.html');
}

Это, конечно, не работает. Браузер пытается загрузить .html-файл как шрифт и получает ошибку. Пришлось пройтись по всем CSS-файлам и убрать лишние расширения из путей к шрифтам.

Вторая проблема — внутренние ссылки. wget конвертировал их в локальные пути, но нам нужно, чтобы навигация по сайту вела на оригинальный norma33.ru. Черновик — это только главная страница, остальные разделы живут на оригинале. Поэтому все href="/..." должны быть абсолютными ссылками на norma33.ru.

После этих правок структура проекта выглядела так:

public/
  index.html
  css/
    style.css
    ...
  fonts/
    GothamPro.woff2
    ...
  js/
    main.js
    ...
  images/
    ...

Docker + nginx: минимальная инфраструктура для статики

Для раздачи статического сайта не нужен Next.js или Node.js. Достаточно nginx в Docker-контейнере. Dockerfile получился лаконичным:

FROM nginx:alpine
COPY public/ /usr/share/nginx/html/
EXPOSE 80

Nginx из коробки умеет раздавать статику, кэшировать, сжимать — всё что нужно. Alpine-образ весит меньше 10 МБ. Идеальное решение для задачи.

Для деплоя использовал Dokploy — self-hosted платформу для управления Docker Swarm. Она живёт на Hetzner VPS и управляет несколькими проектами через единый Traefik reverse proxy.

DNS: где живёт wildcard и почему это важно

Здесь началось самое интересное. Wildcard-запись *.pashavin.ru указывает на Vercel — там живут основные проекты на Next.js. Но Dokploy с Docker Swarm работает на отдельном VPS с другим IP.

Значит, для norma33.pashavin.ru нужна отдельная A-запись, которая перекрывает wildcard и указывает на нужный сервер. DNS-записи управляются через Vercel DNS API:

vercel dns add pashavin.ru norma33 A <IP_СЕРВЕРА>

После добавления записи нужно подождать пропагации — обычно от нескольких минут до часа. В процессе отладки выяснилось, что Dokploy-инстанс находится на одном IP, а деплой через GitHub Actions пытался подключиться к другому. Это привело к серии ошибок service not found и connection refused.

Ключевой урок: всегда проверяй, на каком именно сервере запущен Traefik, и убедись, что DNS указывает именно туда.

GitHub Actions: автоматический деплой

Workflow для деплоя стандартный: сборка Docker-образа, push в GitHub Container Registry (GHCR), SSH на VPS и обновление Docker Swarm сервиса.

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
      
      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: root
          key: ${{ secrets.SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:latest
            docker service update --force \
              --image ghcr.io/${{ github.repository }}:latest \
              app-copy-mobile-program-4dvp7f

Одна из проблем, с которой столкнулся: на VPS в /root/.docker/config.json лежал протухший GitHub App токен. Docker pull проходил успешно на этапе сборки, но VPS не мог забрать образ из GHCR. Фикс — переавторизация root в GHCR на VPS с актуальным токеном.

Параллельная задача: форма кастдева

Пока разбирался с инфраструктурой клона, параллельно шла другая задача — страница /kastdev2026 с анкетой для кастдев-интервью. Задача: собрать 10 человек на 30-40 минутный созвон, обсудить использование нейросетей.

Форма включала:

  • Имя
  • Контакт для связи (Telegram или телефон)
  • Два вопроса с radio-группами
  • Согласие на обработку данных

Данные уходят в CRM (Twenty CRM), уведомления в Telegram изначально были включены, но потом отключены по просьбе — заявки должны были идти только в CRM.

// app/api/kastdev2026/route.ts
export async function POST(req: Request) {
  const body = await req.json();
  
  // Валидация
  const { name, contact, usage, blocker, consent } = body;
  if (!name || !contact || !usage || !blocker || !consent) {
    return NextResponse.json({ error: 'Заполните все поля' }, { status: 400 });
  }
  
  // Только CRM, без Telegram
  await processLeadToCRM({ name, contact, usage, blocker });
  
  return NextResponse.json({ ok: true });
}

Главный баг: валидация контактов теряла лиды

Самая болезненная часть проекта. Изначально в форме была строгая валидация телефонных номеров через libphonenumber-js. Логика казалась разумной: парсим номер, определяем страну, нормализуем формат для CRM.

Но реальные пользователи вводят контакты как угодно:

  • 4915123456789 — немецкий номер без плюса
  • 49 151 23456789 — с пробелами
  • +7 (915) 123-45-67 — российский с форматированием
  • @username — Telegram хэндл
  • @Иван — кириллический хэндл
  • t.me/username — ссылка на Telegram
  • telegram.me/username — альтернативный формат
  • Иван, звонить после 18 — произвольный текст

Валидация отбивала всё, что не распознавала. Заявки терялись молча — пользователь видел ошибку, уходил, лид пропадал.

Решение оказалось простым: убрать все отказы. Контакт принимается в любом виде, разбор остаётся только для красивой карточки в CRM:

function parseContact(raw: string): ParsedContact {
  const cleaned = raw.trim();
  
  // Пробуем распознать Telegram
  const tgPatterns = [
    /(?:t\.me|telegram\.me|tg:\/\/resolve\?domain=)\/?([\w]+)/i,
    /^@?([\w]{5,32})$/,
  ];
  
  for (const pattern of tgPatterns) {
    const match = cleaned.match(pattern);
    if (match) {
      return { type: 'telegram', handle: match[1], raw: cleaned };
    }
  }
  
  // Пробуем распознать телефон
  try {
    const phone = parsePhoneNumber(cleaned.startsWith('+') ? cleaned : '+' + cleaned);
    if (phone?.isValid()) {
      return { type: 'phone', formatted: phone.formatInternational(), raw: cleaned };
    }
  } catch {}
  
  // Любой другой ввод — принимаем как есть
  return { type: 'unknown', raw: cleaned };
}

Ключевой принцип: валидация не должна решать, принять заявку или нет. Её задача — помочь структурировать данные. Если не получается — сохраняем как есть и разбираемся вручную.

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

Проблема с производительностью сборки

Отдельная история — время сборки в GitHub Actions. Из-за добавления libphonenumber-js сбросился кэш слоя npm ci, и сборка стала занимать 30-40 минут вместо обычных 3-5.

Причина системная: --mount=type=cache в Dockerfile не работает в GitHub Actions. Cache mount живёт в локальном состоянии билдера, а раннер каждый запуск чистый. Реальное решение — кэширование слоёв через cache-from: type=gha и cache-to: type=gha,mode=max в docker/build-push-action.

После настройки кэша слоёв время сборки вернулось к норме: 5-6 минут на первый прогон (заполнение кэша), 2-3 минуты на последующие.

Также поправили rate limit на API роуте — изначально стоял лимит 2 запроса в минуту на IP. Это ломало пользователей за общим NAT (мобильный оператор, корпоративный WiFi), где несколько человек могут заполнять форму одновременно с одного IP.

Результат

По итогу получили два рабочих продукта:

Черновик сайта клиники на norma33.pashavin.ru — pixel-perfect копия главной страницы, развёрнутая в Docker через nginx, с автоматическим деплоем через GitHub Actions. Можно экспериментировать с дизайном, не трогая оригинал.

Форма кастдева на /kastdev2026 — принимает контакты в любом формате, сохраняет в CRM, не теряет лиды из-за валидации. Несколько человек уже заполнили анкету успешно.

Выводы

Главный урок этого проекта — инфраструктурные задачи всегда сложнее, чем кажутся. Скопировать сайт — 10 минут. Починить пути к шрифтам, разобраться с DNS, настроить правильный SSH-ключ, понять почему Traefik не видит сервис — это часы работы. Именно поэтому важно иметь чёткое понимание всей цепочки: от DNS до Docker Swarm лейблов.

Второй урок — валидация на стороне сервера должна быть максимально лояльной, когда речь идёт о сборе лидов. Строгая валидация телефонных номеров кажется правильной практикой, но на практике она отбивает реальных людей с реальными деньгами. Лучше принять «грязный» контакт и разобраться вручную, чем потерять заявку навсегда.

Третий урок — кэширование в CI/CD нужно настраивать осознанно. --mount=type=cache в Dockerfile выглядит как решение, но в GitHub Actions это иллюзия. Реальное кэширование — через cache-from/cache-to с GHA backend. Разница между 30 минутами и 3 минутами сборки — это именно правильно настроенный кэш слоёв.

Четвёртый урок — staging-окружение полезно не только для кода. Дизайнеры и маркетологи тоже нуждаются в безопасном месте для экспериментов. Поддомен с копией главной страницы — это дёшево (один Docker-контейнер) и очень удобно для итеративной работы над дизайном без риска сломать прод.

Технологии

  • nginx — раздача статики в Docker-контейнере
  • Docker Swarm — оркестрация контейнеров на VPS
  • Traefik — reverse proxy с автоматическими SSL-сертификатами
  • GitHub Actions — CI/CD пайплайн
  • libphonenumber-js — парсинг телефонных номеров
  • Twenty CRM — open-source CRM для хранения лидов
Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.