Staging сайт клиники: клон, деплой и форма кастдева
Однажды мне прилетела задача: «скопируй главную страницу и подними 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 80Nginx из коробки умеет раздавать статику, кэшировать, сжимать — всё что нужно. 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— ссылка на Telegramtelegram.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 в бизнес.